diff --git a/.agents/skills/cut-release-tag/SKILL.md b/.agents/skills/cut-release-tag/SKILL.md new file mode 100644 index 000000000..488d3d32d --- /dev/null +++ b/.agents/skills/cut-release-tag/SKILL.md @@ -0,0 +1,90 @@ +--- +name: cut-release-tag +description: Cut a new semver release tag on main, move the `latest` tag, and push. Use when cutting a release, tagging a version, shipping a build, or preparing a deployment. Trigger keywords - cut tag, release tag, new tag, cut release, tag version, ship it. +user_invocable: true +--- + +# Cut Release Tag + +Create an annotated semver tag on `origin/main` HEAD, move the `latest` floating tag, and push both. + +## Prerequisites + +- You must be in the NemoClaw git repository. +- You must have push access to `origin` (NVIDIA/NemoClaw). +- The nightly E2E suite should have passed before tagging. Check with the user if unsure. + +## Step 1: Determine the Current Version + +Fetch all tags and find the latest semver tag: + +```bash +git fetch origin --tags +git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1 +``` + +Parse the major, minor, and patch components from this tag. + +## Step 2: Ask the User Which Bump + +Present the options with the **patch bump as default**: + +- **Patch** (default): `vX.Y.(Z+1)` — bug fixes, small changes +- **Minor**: `vX.(Y+1).0` — new features, larger changes +- **Major**: `v(X+1).0.0` — breaking changes + +Show the concrete version strings. Example prompt: + +> Current tag: `v0.0.2` +> +> Which version bump? +> +> 1. **Patch** → `v0.0.3` (default) +> 2. **Minor** → `v0.1.0` +> 3. **Major** → `v1.0.0` + +Wait for the user to confirm before proceeding. If they just say "yes", "go", "do it", or similar, use the patch default. + +## Step 3: Show What's Being Tagged + +Show the user the commit that will be tagged and the changelog since the last tag: + +```bash +git log --oneline origin/main -1 +git log --oneline ..origin/main +``` + +Ask for confirmation: "Tag `` at commit ``?" + +## Step 4: Create and Push Tags + +Create the annotated tag, move `latest`, and push: + +```bash +# Create annotated tag on main HEAD +git tag -a origin/main -m "" + +# Move the latest tag (delete old, create new) +git tag -d latest 2>/dev/null || true +git tag -a latest origin/main -m "latest" + +# Push both tags (force-push latest since it moves) +git push origin +git push origin latest --force +``` + +## Step 5: Verify + +```bash +git ls-remote --tags origin | grep -E '(|latest)' +``` + +Confirm both tags point to the same commit on the remote. + +## Important Notes + +- NEVER tag without explicit user confirmation of the version. +- NEVER tag a branch other than `origin/main`. +- Always use annotated tags (`-a`), not lightweight tags. +- The `latest` tag is a floating tag that always points to the most recent release — it requires `--force` to push. +- Do NOT update `package.json` version — that is handled separately. diff --git a/.agents/skills/nemoclaw-configure-inference/SKILL.md b/.agents/skills/nemoclaw-configure-inference/SKILL.md index 5c0521a8b..d33643a57 100644 --- a/.agents/skills/nemoclaw-configure-inference/SKILL.md +++ b/.agents/skills/nemoclaw-configure-inference/SKILL.md @@ -1,16 +1,72 @@ --- name: nemoclaw-configure-inference -description: Changes the active inference model without restarting the sandbox. Use when change inference runtime, inference routing, openclaw, openshell, switch nemoclaw inference model, switch nemoclaw inference models. +description: Lists all inference providers offered during NemoClaw onboarding. Use when explaining which providers are available, what the onboard wizard presents, or how inference routing works. Changes the active inference model without restarting the sandbox. Use when switching inference providers, changing the model runtime, or reconfiguring inference routing. Connects NemoClaw to a local inference server. Use when setting up Ollama, vLLM, TensorRT-LLM, NIM, or any OpenAI-compatible local model server with NemoClaw. --- -# Nemoclaw Configure Inference +# NemoClaw Configure Inference -Change the active inference model without restarting the sandbox. +Lists all inference providers offered during NemoClaw onboarding. Use when explaining which providers are available, what the onboard wizard presents, or how inference routing works. + +## Context + +NemoClaw supports multiple inference providers. +During onboarding, the `nemoclaw onboard` wizard presents a numbered list of providers to choose from. +Your selection determines where the agent's inference traffic is routed. + +## How Inference Routing Works + +The agent inside the sandbox talks to `inference.local`. +It never connects to a provider directly. +OpenShell intercepts inference traffic on the host and forwards it to the provider you selected. + +Provider credentials stay on the host. +The sandbox does not receive your API key. + +## Provider Options + +The onboard wizard presents the following provider options by default. +The first six are always available. +Ollama appears when it is installed or running on the host. + +| Option | Description | Curated models | +|--------|-------------|----------------| +| NVIDIA Endpoints | Routes to models hosted on [build.nvidia.com](https://build.nvidia.com). You can also enter any model ID from the catalog. Set `NVIDIA_API_KEY`. | Nemotron 3 Super 120B, Kimi K2.5, GLM-5, MiniMax M2.5, GPT-OSS 120B | +| OpenAI | Routes to the OpenAI API. Set `OPENAI_API_KEY`. | `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, `gpt-5.4-pro-2026-03-05` | +| Other OpenAI-compatible endpoint | Routes to any server that implements `/v1/chat/completions`. The wizard prompts for a base URL and model name. Works with OpenRouter, LocalAI, llama.cpp, or any compatible proxy. Set `COMPATIBLE_API_KEY`. | You provide the model name. | +| Anthropic | Routes to the Anthropic Messages API. Set `ANTHROPIC_API_KEY`. | `claude-sonnet-4-6`, `claude-haiku-4-5`, `claude-opus-4-6` | +| Other Anthropic-compatible endpoint | Routes to any server that implements the Anthropic Messages API (`/v1/messages`). The wizard prompts for a base URL and model name. Set `COMPATIBLE_ANTHROPIC_API_KEY`. | You provide the model name. | +| Google Gemini | Routes to Google's OpenAI-compatible endpoint. Set `GEMINI_API_KEY`. | `gemini-3.1-pro-preview`, `gemini-3.1-flash-lite-preview`, `gemini-3-flash-preview`, `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.5-flash-lite` | +| Local Ollama | Routes to a local Ollama instance on `localhost:11434`. NemoClaw detects installed models, offers starter models if none are present, pulls and warms the selected model, and validates it. | Selected during onboarding. For more information, refer to Use a Local Inference Server (see the `nemoclaw-configure-inference` skill). | + +## Experimental Options + +The following local inference options require `NEMOCLAW_EXPERIMENTAL=1` and, when prerequisites are met, appear in the onboarding selection list. + +| Option | Condition | Notes | +|--------|-----------|-------| +| Local NVIDIA NIM | NIM-capable GPU detected | Pulls and manages a NIM container. | +| Local vLLM | vLLM running on `localhost:8000` | Auto-detects the loaded model. | + +For setup instructions, refer to Use a Local Inference Server (see the `nemoclaw-configure-inference` skill). + +## Validation + +NemoClaw validates the selected provider and model before creating the sandbox. +If validation fails, the wizard returns to provider selection. + +| Provider type | Validation method | +|---|---| +| OpenAI-compatible | Tries `/responses` first, then `/chat/completions`. | +| Anthropic-compatible | Tries `/v1/messages`. | +| NVIDIA Endpoints (manual model entry) | Validates the model name against the catalog API. | +| Compatible endpoints | Sends a real inference request because many proxies do not expose a `/models` endpoint. | ## Prerequisites - A running NemoClaw sandbox. - The OpenShell CLI on your `PATH`. +- NemoClaw installed. +- A local model server running, or Ollama installed. The NemoClaw onboard wizard can also start Ollama for you. Change the active inference model while the sandbox is running. No restart is required. @@ -80,6 +136,200 @@ The output includes the active provider, model, and endpoint. - The sandbox continues to use `inference.local`. - Runtime switching changes the OpenShell route. It does not rewrite your stored credentials. +--- + +NemoClaw can route inference to a model server running on your machine instead of a cloud API. +This page covers Ollama, compatible-endpoint paths for other servers, and two experimental options for vLLM and NVIDIA NIM. + +All approaches use the same `inference.local` routing model. +The agent inside the sandbox never connects to your model server directly. +OpenShell intercepts inference traffic and forwards it to the local endpoint you configure. + +## Step 4: Ollama + +Ollama is the default local inference option. +The onboard wizard detects Ollama automatically when it is installed or running on the host. + +If Ollama is not running, NemoClaw starts it for you. +On macOS, the wizard also offers to install Ollama through Homebrew if it is not present. + +Run the onboard wizard. + +```console +$ nemoclaw onboard +``` + +Select **Local Ollama** from the provider list. +NemoClaw lists installed models or offers starter models if none are installed. +It pulls the selected model, loads it into memory, and validates it before continuing. + +### Linux with Docker + +On Linux hosts that run NemoClaw with Docker, the sandbox reaches Ollama through +`http://host.openshell.internal:11434`, not the host shell's `localhost` socket. +If Ollama is already running, make sure it listens on `0.0.0.0:11434` instead of +`127.0.0.1:11434`. + +```console +$ OLLAMA_HOST=0.0.0.0:11434 ollama serve +``` + +If Ollama only binds loopback, NemoClaw can detect it on the host, but the +sandbox-side validation step fails because containers cannot reach it. + +### Non-Interactive Setup + +```console +$ NEMOCLAW_PROVIDER=ollama \ + NEMOCLAW_MODEL=qwen2.5:14b \ + nemoclaw onboard --non-interactive +``` + +If `NEMOCLAW_MODEL` is not set, NemoClaw selects a default model based on available memory. + +| Variable | Purpose | +|---|---| +| `NEMOCLAW_PROVIDER` | Set to `ollama`. | +| `NEMOCLAW_MODEL` | Ollama model tag to use. Optional. | + +## Step 5: OpenAI-Compatible Server + +This option works with any server that implements `/v1/chat/completions`, including vLLM, TensorRT-LLM, llama.cpp, LocalAI, and others. + +Start your model server. +The examples below use vLLM, but any OpenAI-compatible server works. + +```console +$ vllm serve meta-llama/Llama-3.1-8B-Instruct --port 8000 +``` + +Run the onboard wizard. + +```console +$ nemoclaw onboard +``` + +When the wizard asks you to choose an inference provider, select **Other OpenAI-compatible endpoint**. +Enter the base URL of your local server, for example `http://localhost:8000/v1`. + +The wizard prompts for an API key. +If your server does not require authentication, enter any non-empty string (for example, `dummy`). + +NemoClaw validates the endpoint by sending a test inference request before continuing. + +### Non-Interactive Setup + +Set the following environment variables for scripted or CI/CD deployments. + +```console +$ NEMOCLAW_PROVIDER=custom \ + NEMOCLAW_ENDPOINT_URL=http://localhost:8000/v1 \ + NEMOCLAW_MODEL=meta-llama/Llama-3.1-8B-Instruct \ + COMPATIBLE_API_KEY=dummy \ + nemoclaw onboard --non-interactive +``` + +| Variable | Purpose | +|---|---| +| `NEMOCLAW_PROVIDER` | Set to `custom` for an OpenAI-compatible endpoint. | +| `NEMOCLAW_ENDPOINT_URL` | Base URL of the local server. | +| `NEMOCLAW_MODEL` | Model ID as reported by the server. | +| `COMPATIBLE_API_KEY` | API key for the endpoint. Use any non-empty value if authentication is not required. | + +## Step 6: Anthropic-Compatible Server + +If your local server implements the Anthropic Messages API (`/v1/messages`), choose **Other Anthropic-compatible endpoint** during onboarding instead. + +```console +$ nemoclaw onboard +``` + +For non-interactive setup, use `NEMOCLAW_PROVIDER=anthropicCompatible` and set `COMPATIBLE_ANTHROPIC_API_KEY`. + +```console +$ NEMOCLAW_PROVIDER=anthropicCompatible \ + NEMOCLAW_ENDPOINT_URL=http://localhost:8080 \ + NEMOCLAW_MODEL=my-model \ + COMPATIBLE_ANTHROPIC_API_KEY=dummy \ + nemoclaw onboard --non-interactive +``` + +## Step 7: vLLM Auto-Detection (Experimental) + +When vLLM is already running on `localhost:8000`, NemoClaw can detect it automatically and query the `/v1/models` endpoint to determine the loaded model. + +Set the experimental flag and run onboard. + +```console +$ NEMOCLAW_EXPERIMENTAL=1 nemoclaw onboard +``` + +Select **Local vLLM [experimental]** from the provider list. +NemoClaw detects the running model and validates the endpoint. + +> **Note:** NemoClaw forces the `chat/completions` API path for vLLM. +> The vLLM `/v1/responses` endpoint does not run the `--tool-call-parser`, so tool calls arrive as raw text. + +### Non-Interactive Setup + +```console +$ NEMOCLAW_EXPERIMENTAL=1 \ + NEMOCLAW_PROVIDER=vllm \ + nemoclaw onboard --non-interactive +``` + +NemoClaw auto-detects the model from the running vLLM instance. +To override the model, set `NEMOCLAW_MODEL`. + +## Step 8: NVIDIA NIM (Experimental) + +NemoClaw can pull, start, and manage a NIM container on hosts with a NIM-capable NVIDIA GPU. + +Set the experimental flag and run onboard. + +```console +$ NEMOCLAW_EXPERIMENTAL=1 nemoclaw onboard +``` + +Select **Local NVIDIA NIM [experimental]** from the provider list. +NemoClaw filters available models by GPU VRAM, pulls the NIM container image, starts it, and waits for it to become healthy before continuing. + +> **Note:** NIM uses vLLM internally. +> The same `chat/completions` API path restriction applies. + +### Non-Interactive Setup + +```console +$ NEMOCLAW_EXPERIMENTAL=1 \ + NEMOCLAW_PROVIDER=nim \ + nemoclaw onboard --non-interactive +``` + +To select a specific model, set `NEMOCLAW_MODEL`. + +## Step 9: Verify the Configuration + +After onboarding completes, confirm the active provider and model. + +```console +$ nemoclaw status +``` + +The output shows the provider label (for example, "Local vLLM" or "Other OpenAI-compatible endpoint") and the active model. + +## Step 10: Switch Models at Runtime + +You can change the model without re-running onboard. +Refer to Switch Inference Models (see the `nemoclaw-configure-inference` skill) for the full procedure. + +For compatible endpoints, the command is: + +```console +$ openshell inference set --provider compatible-endpoint --model +``` + +If the provider itself needs to change (for example, switching from vLLM to a cloud API), rerun `nemoclaw onboard`. + ## Related Skills -- `nemoclaw-reference` — Inference Profiles for full profile configuration details +- `nemoclaw-get-started` — Quickstart for first-time installation diff --git a/.agents/skills/nemoclaw-configure-inference/references/inference-options.md b/.agents/skills/nemoclaw-configure-inference/references/inference-options.md new file mode 100644 index 000000000..b4224b728 --- /dev/null +++ b/.agents/skills/nemoclaw-configure-inference/references/inference-options.md @@ -0,0 +1,58 @@ +# Inference Options + +NemoClaw supports multiple inference providers. +During onboarding, the `nemoclaw onboard` wizard presents a numbered list of providers to choose from. +Your selection determines where the agent's inference traffic is routed. + +## How Inference Routing Works + +The agent inside the sandbox talks to `inference.local`. +It never connects to a provider directly. +OpenShell intercepts inference traffic on the host and forwards it to the provider you selected. + +Provider credentials stay on the host. +The sandbox does not receive your API key. + +## Provider Options + +The onboard wizard presents the following provider options by default. +The first six are always available. +Ollama appears when it is installed or running on the host. + +| Option | Description | Curated models | +|--------|-------------|----------------| +| NVIDIA Endpoints | Routes to models hosted on [build.nvidia.com](https://build.nvidia.com). You can also enter any model ID from the catalog. Set `NVIDIA_API_KEY`. | Nemotron 3 Super 120B, Kimi K2.5, GLM-5, MiniMax M2.5, GPT-OSS 120B | +| OpenAI | Routes to the OpenAI API. Set `OPENAI_API_KEY`. | `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, `gpt-5.4-pro-2026-03-05` | +| Other OpenAI-compatible endpoint | Routes to any server that implements `/v1/chat/completions`. The wizard prompts for a base URL and model name. Works with OpenRouter, LocalAI, llama.cpp, or any compatible proxy. Set `COMPATIBLE_API_KEY`. | You provide the model name. | +| Anthropic | Routes to the Anthropic Messages API. Set `ANTHROPIC_API_KEY`. | `claude-sonnet-4-6`, `claude-haiku-4-5`, `claude-opus-4-6` | +| Other Anthropic-compatible endpoint | Routes to any server that implements the Anthropic Messages API (`/v1/messages`). The wizard prompts for a base URL and model name. Set `COMPATIBLE_ANTHROPIC_API_KEY`. | You provide the model name. | +| Google Gemini | Routes to Google's OpenAI-compatible endpoint. Set `GEMINI_API_KEY`. | `gemini-3.1-pro-preview`, `gemini-3.1-flash-lite-preview`, `gemini-3-flash-preview`, `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.5-flash-lite` | +| Local Ollama | Routes to a local Ollama instance on `localhost:11434`. NemoClaw detects installed models, offers starter models if none are present, pulls and warms the selected model, and validates it. | Selected during onboarding. For more information, refer to Use a Local Inference Server (see the `nemoclaw-configure-inference` skill). | + +## Experimental Options + +The following local inference options require `NEMOCLAW_EXPERIMENTAL=1` and, when prerequisites are met, appear in the onboarding selection list. + +| Option | Condition | Notes | +|--------|-----------|-------| +| Local NVIDIA NIM | NIM-capable GPU detected | Pulls and manages a NIM container. | +| Local vLLM | vLLM running on `localhost:8000` | Auto-detects the loaded model. | + +For setup instructions, refer to Use a Local Inference Server (see the `nemoclaw-configure-inference` skill). + +## Validation + +NemoClaw validates the selected provider and model before creating the sandbox. +If validation fails, the wizard returns to provider selection. + +| Provider type | Validation method | +|---|---| +| OpenAI-compatible | Tries `/responses` first, then `/chat/completions`. | +| Anthropic-compatible | Tries `/v1/messages`. | +| NVIDIA Endpoints (manual model entry) | Validates the model name against the catalog API. | +| Compatible endpoints | Sends a real inference request because many proxies do not expose a `/models` endpoint. | + +## Next Steps + +- Use a Local Inference Server (see the `nemoclaw-configure-inference` skill) for Ollama, vLLM, NIM, and compatible-endpoint setup details. +- Switch Inference Models (see the `nemoclaw-configure-inference` skill) for changing the model at runtime without re-onboarding. diff --git a/.agents/skills/nemoclaw-deploy-remote/SKILL.md b/.agents/skills/nemoclaw-deploy-remote/SKILL.md index c69a3a6f8..08ab0bd53 100644 --- a/.agents/skills/nemoclaw-deploy-remote/SKILL.md +++ b/.agents/skills/nemoclaw-deploy-remote/SKILL.md @@ -1,11 +1,11 @@ --- name: nemoclaw-deploy-remote -description: Provisions a remote GPU VM with NemoClaw using Brev deployment. Also covers forwards messages between Telegram and the sandboxed OpenClaw agent. Use when deploy nemoclaw remote gpu, deployment, gpu, nemoclaw, nemoclaw brev cloud deployment, nemoclaw telegram bridge, openclaw, openshell. +description: Provisions a remote GPU VM with NemoClaw using Brev deployment. Use when deploying to a cloud GPU, setting up a remote NemoClaw instance, or configuring Brev. Describes security hardening measures applied to the NemoClaw sandbox container image. Use when reviewing container security, Docker capabilities, process limits, or sandbox hardening controls. Forwards messages between Telegram and the sandboxed OpenClaw agent. Use when setting up a Telegram bot bridge, connecting a chat interface, or configuring Telegram integration. --- -# Nemoclaw Deploy Remote +# NemoClaw Deploy Remote -Provision a remote GPU VM with NemoClaw using Brev deployment. +Provisions a remote GPU VM with NemoClaw using Brev deployment. Use when deploying to a cloud GPU, setting up a remote NemoClaw instance, or configuring Brev. ## Prerequisites @@ -18,7 +18,20 @@ Provision a remote GPU VM with NemoClaw using Brev deployment. Run NemoClaw on a remote GPU instance through [Brev](https://brev.nvidia.com). The deploy command provisions the VM, installs dependencies, and connects you to a running sandbox. -## Step 1: Deploy the Instance +## Step 1: Quick Start + +If your Brev instance is already up and you want to try NemoClaw immediately, start with the sandbox chat flow: + +```console +$ nemoclaw my-assistant connect +$ openclaw tui +``` + +This gets you into the sandbox shell first and opens the OpenClaw chat UI right away. + +If you are connecting from your local machine and still need to provision the remote VM, use `nemoclaw deploy ` as described below. + +## Step 2: Deploy the Instance > **Warning:** The `nemoclaw deploy` command is experimental and may not work as expected. @@ -34,10 +47,10 @@ The deploy script performs the following steps on the VM: 1. Installs Docker and the NVIDIA Container Toolkit if a GPU is present. 2. Installs the OpenShell CLI. -3. Runs the nemoclaw setup to create the gateway, register providers, and launch the sandbox. +3. Runs `nemoclaw onboard` (the setup wizard) to create the gateway, register providers, and launch the sandbox. 4. Starts auxiliary services, such as the Telegram bridge and cloudflared tunnel. -## Step 2: Connect to the Remote Sandbox +## Step 3: Connect to the Remote Sandbox After deployment finishes, the deploy command opens an interactive shell inside the remote sandbox. To reconnect after closing the session, run the deploy command again: @@ -46,7 +59,7 @@ To reconnect after closing the session, run the deploy command again: $ nemoclaw deploy ``` -## Step 3: Monitor the Remote Sandbox +## Step 4: Monitor the Remote Sandbox SSH to the instance and run the OpenShell TUI to monitor activity and approve network requests: @@ -54,7 +67,7 @@ SSH to the instance and run the OpenShell TUI to monitor activity and approve ne $ ssh 'cd /home/ubuntu/nemoclaw && set -a && . .env && set +a && openshell term' ``` -## Step 4: Verify Inference +## Step 5: Verify Inference Run a test agent prompt inside the remote sandbox: @@ -62,7 +75,27 @@ Run a test agent prompt inside the remote sandbox: $ openclaw agent --agent main --local -m "Hello from the remote sandbox" --session-id test ``` -## Step 5: GPU Configuration +## Step 6: Remote Dashboard Access + +The NemoClaw dashboard validates the browser origin against an allowlist baked +into the sandbox image at build time. By default the allowlist only contains +`http://127.0.0.1:18789`. When accessing the dashboard from a remote browser +(for example through a Brev public URL or an SSH port-forward), set +`CHAT_UI_URL` to the origin the browser will use **before** running setup: + +```console +$ export CHAT_UI_URL="https://openclaw0-.brevlab.com" +$ nemoclaw deploy +``` + +For SSH port-forwarding, the origin is typically `http://127.0.0.1:18789` (the +default), so no extra configuration is needed. + +> **Note:** On Brev, set `CHAT_UI_URL` in the launchable environment configuration so it is +> available when the setup script builds the sandbox image. If `CHAT_UI_URL` is +> not set on a headless host, `brev-setup.sh` prints a warning. + +## Step 7: GPU Configuration The deploy script uses the `NEMOCLAW_GPU` environment variable to select the GPU type. The default value is `a2-highgpu-1g:nvidia-tesla-a100:1`. @@ -78,12 +111,12 @@ $ nemoclaw deploy Forward messages between a Telegram bot and the OpenClaw agent running inside the sandbox. The Telegram bridge is an auxiliary service managed by `nemoclaw start`. -## Step 6: Create a Telegram Bot +## Step 8: Create a Telegram Bot Open Telegram and send `/newbot` to [@BotFather](https://t.me/BotFather). Follow the prompts to create a bot and receive a bot token. -## Step 7: Set the Environment Variable +## Step 9: Set the Environment Variable Export the bot token as an environment variable: @@ -91,7 +124,7 @@ Export the bot token as an environment variable: $ export TELEGRAM_BOT_TOKEN= ``` -## Step 8: Start Auxiliary Services +## Step 10: Start Auxiliary Services Start the Telegram bridge and other auxiliary services: @@ -106,7 +139,7 @@ The `start` command launches the following services: The Telegram bridge starts only when the `TELEGRAM_BOT_TOKEN` environment variable is set. -## Step 9: Verify the Services +## Step 11: Verify the Services Check that the Telegram bridge is running: @@ -116,12 +149,12 @@ $ nemoclaw status The output shows the status of all auxiliary services. -## Step 10: Send a Message +## Step 12: Send a Message Open Telegram, find your bot, and send a message. The bridge forwards the message to the OpenClaw agent inside the sandbox and returns the agent response. -## Step 11: Restrict Access by Chat ID +## Step 13: Restrict Access by Chat ID To restrict which Telegram chats can interact with the agent, set the `ALLOWED_CHAT_IDS` environment variable to a comma-separated list of Telegram chat IDs: @@ -130,7 +163,7 @@ $ export ALLOWED_CHAT_IDS="123456789,987654321" $ nemoclaw start ``` -## Step 12: Stop the Services +## Step 14: Stop the Services To stop the Telegram bridge and all other auxiliary services: @@ -138,6 +171,10 @@ To stop the Telegram bridge and all other auxiliary services: $ nemoclaw stop ``` +## Reference + +- [Sandbox Image Hardening](references/sandbox-hardening.md) + ## Related Skills - `nemoclaw-monitor-sandbox` — Monitor Sandbox Activity for sandbox monitoring tools diff --git a/.agents/skills/nemoclaw-deploy-remote/references/sandbox-hardening.md b/.agents/skills/nemoclaw-deploy-remote/references/sandbox-hardening.md new file mode 100644 index 000000000..bde7aac28 --- /dev/null +++ b/.agents/skills/nemoclaw-deploy-remote/references/sandbox-hardening.md @@ -0,0 +1,68 @@ +# Sandbox Image Hardening + +The NemoClaw sandbox image applies several security measures to reduce attack +surface and limit the blast radius of untrusted workloads. + +## Removed Unnecessary Tools + +Build toolchains (`gcc`, `g++`, `make`) and network probes (`netcat`) are +explicitly purged from the runtime image. These tools are not needed at runtime +and would unnecessarily widen the attack surface. + +If you need a compiler during build, use the existing multi-stage build +(the `builder` stage has full Node.js tooling) and copy only artifacts into the +runtime stage. + +## Process Limits + +The container ENTRYPOINT sets `ulimit -u 512` to cap the number of processes +a sandbox user can spawn. This mitigates fork-bomb attacks. The startup script +(`nemoclaw-start.sh`) applies the same limit. + +Adjust the value via the `--ulimit nproc=512:512` flag if launching with +`docker run` directly. + +## Dropping Linux Capabilities + +When running the sandbox container, drop all Linux capabilities and re-add only +what is strictly required: + +```console +$ docker run --rm \ + --cap-drop=ALL \ + --ulimit nproc=512:512 \ + nemoclaw-sandbox +``` + +### Docker Compose Example + +```yaml +services: + nemoclaw-sandbox: + image: nemoclaw-sandbox:latest + cap_drop: + - ALL + cap_add: + - NET_BIND_SERVICE + ulimits: + nproc: + soft: 512 + hard: 512 + security_opt: + - no-new-privileges:true + read_only: true + tmpfs: + - /tmp:size=64m +``` + +> **Note:** The `Dockerfile` itself cannot enforce `--cap-drop` — that is a +> runtime concern controlled by the container orchestrator. Always configure +> capability dropping in your `docker run` flags, Compose file, or Kubernetes +> `securityContext`. + +## References + +- [#807](https://github.com/NVIDIA/NemoClaw/issues/807) — gcc in sandbox image +- [#808](https://github.com/NVIDIA/NemoClaw/issues/808) — netcat in sandbox image +- [#809](https://github.com/NVIDIA/NemoClaw/issues/809) — No process limit +- [#797](https://github.com/NVIDIA/NemoClaw/issues/797) — Drop Linux capabilities diff --git a/.agents/skills/nemoclaw-get-started/SKILL.md b/.agents/skills/nemoclaw-get-started/SKILL.md index e1e8c1b32..254016160 100644 --- a/.agents/skills/nemoclaw-get-started/SKILL.md +++ b/.agents/skills/nemoclaw-get-started/SKILL.md @@ -1,11 +1,15 @@ --- name: nemoclaw-get-started -description: Installs NemoClaw, launch a sandbox, and run your first agent prompt. Use when inference routing, install nemoclaw openclaw sandbox, nemoclaw, nemoclaw quickstart, nemoclaw quickstart install launch, openclaw, openshell, sandboxing. +description: Installs NemoClaw, launches a sandbox, and runs the first agent prompt. Use when onboarding, installing, or launching a NemoClaw sandbox for the first time. --- -# Nemoclaw Get Started +# NemoClaw Get Started -Install NemoClaw, launch a sandbox, and run your first agent prompt. +Installs NemoClaw, launches a sandbox, and runs the first agent prompt. Use when onboarding, installing, or launching a NemoClaw sandbox for the first time. + +## Prerequisites + +Before getting started, check the prerequisites to ensure you have the necessary software and hardware to run NemoClaw. > **Alpha software:** NemoClaw is in alpha, available as an early preview since March 16, 2026. > APIs, configuration schemas, and runtime behavior are subject to breaking changes between releases. @@ -14,50 +18,13 @@ Install NemoClaw, launch a sandbox, and run your first agent prompt. Follow these steps to get started with NemoClaw and your first sandboxed OpenClaw agent. -> **Note:** NemoClaw currently requires a fresh installation of OpenClaw. - -## Prerequisites - -Check the prerequisites before you start to ensure you have the necessary software and hardware to run NemoClaw. - -### Hardware - -| Resource | Minimum | Recommended | -|----------|----------------|------------------| -| CPU | 4 vCPU | 4+ vCPU | -| RAM | 8 GB | 16 GB | -| Disk | 20 GB free | 40 GB free | - -The sandbox image is approximately 2.4 GB compressed. During image push, the Docker daemon, k3s, and the OpenShell gateway run alongside the export pipeline, which buffers decompressed layers in memory. On machines with less than 8 GB of RAM, this combined usage can trigger the OOM killer. If you cannot add memory, configuring at least 8 GB of swap can work around the issue at the cost of slower performance. - -#### Software - -| Dependency | Version | -|------------|----------------------------------| -| Linux | Ubuntu 22.04 LTS or later | -| Node.js | 20 or later | -| npm | 10 or later | -| Container runtime | Supported runtime installed and running | -| [OpenShell](https://github.com/NVIDIA/OpenShell) | Installed | - -#### Container Runtime Support - -| Platform | Supported runtimes | Notes | -|----------|--------------------|-------| -| Linux | Docker | Primary supported path today | -| macOS (Apple Silicon) | Colima, Docker Desktop | Recommended runtimes for supported macOS setups | -| macOS | Podman | Not supported yet. NemoClaw currently depends on OpenShell support for Podman on macOS. | -| Windows WSL | Docker Desktop (WSL backend) | Supported target path | - -> **💡 Tip** -> -> For DGX Spark, follow the [DGX Spark setup guide](https://github.com/NVIDIA/NemoClaw/blob/main/spark-install.md). It covers Spark-specific prerequisites, such as cgroup v2 and Docker configuration, before running the standard installer. - -### Install NemoClaw and Onboard OpenClaw Agent +## Step 1: Install NemoClaw and Onboard OpenClaw Agent Download and run the installer script. The script installs Node.js if it is not already present, then runs the guided onboard wizard to create a sandbox, configure inference, and apply security policies. +> **Note:** NemoClaw creates a fresh OpenClaw instance inside the sandbox during the onboarding process. + ```bash curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash ``` @@ -80,65 +47,41 @@ Logs: nemoclaw my-assistant logs --follow [INFO] === Installation complete === ``` -### Chat with the Agent +## Step 2: Chat with the Agent Connect to the sandbox, then chat with the agent through the TUI or the CLI. -#### Connect to the Sandbox - -Run the following command to connect to the sandbox: - ```bash nemoclaw my-assistant connect ``` -This connects you to the sandbox shell `sandbox@my-assistant:~$` where you can run `openclaw` commands. - -#### OpenClaw TUI - -In the sandbox shell, run the following command to open the OpenClaw TUI, which opens an interactive chat interface. +In the sandbox shell, open the OpenClaw terminal UI and start a chat: ```bash openclaw tui ``` -Send a test message to the agent and verify you receive a response. - -> **ℹ️ Note** -> -> The TUI is best for interactive back-and-forth. If you need the full text of a long response such as a large code generation output, use the CLI instead. - -#### OpenClaw CLI - -In the sandbox shell, run the following command to send a single message and print the response: +Alternatively, send a single message and print the response: ```bash openclaw agent --agent main --local -m "hello" --session-id test ``` -This prints the complete response directly in the terminal and avoids relying on the TUI view for long output. +## Step 3: Uninstall -### Uninstall - -To remove NemoClaw and all resources created during setup, in the terminal outside the sandbox, run: +To remove NemoClaw and all resources created during setup, run the uninstall script: ```bash curl -fsSL https://raw.githubusercontent.com/NVIDIA/NemoClaw/refs/heads/main/uninstall.sh | bash ``` -The script removes sandboxes, the NemoClaw gateway and providers, related Docker images and containers, local state directories, and the global `nemoclaw` npm package. It does not remove shared system tooling such as Docker, Node.js, npm, or Ollama. - | Flag | Effect | |--------------------|-----------------------------------------------------| | `--yes` | Skip the confirmation prompt. | | `--keep-openshell` | Leave the `openshell` binary installed. | | `--delete-models` | Also remove NemoClaw-pulled Ollama models. | -For example, to skip the confirmation prompt: - -```bash -curl -fsSL https://raw.githubusercontent.com/NVIDIA/NemoClaw/refs/heads/main/uninstall.sh | bash -s -- --yes -``` +For troubleshooting installation or onboarding issues, see the Troubleshooting guide (see the `nemoclaw-reference` skill). ## Related Skills diff --git a/.agents/skills/nemoclaw-manage-policy/SKILL.md b/.agents/skills/nemoclaw-manage-policy/SKILL.md index 4f23dd219..547cca091 100644 --- a/.agents/skills/nemoclaw-manage-policy/SKILL.md +++ b/.agents/skills/nemoclaw-manage-policy/SKILL.md @@ -1,11 +1,11 @@ --- name: nemoclaw-manage-policy -description: Reviews and approve blocked agent network requests in the TUI. Also covers adds, remove, or modify allowed endpoints in the sandbox policy. Use when approve deny nemoclaw agent, customize nemoclaw network policy, customize nemoclaw sandbox network, nemoclaw, nemoclaw approve network requests, network policy, openclaw, openshell. +description: Reviews and approves blocked agent network requests in the TUI. Use when approving or denying sandbox egress requests, managing blocked network calls, or using the approval TUI. Adds, removes, or modifies allowed endpoints in the sandbox policy. Use when customizing network policy, changing egress rules, or configuring sandbox endpoint access. --- -# Nemoclaw Manage Policy +# NemoClaw Manage Policy -Review and approve blocked agent network requests in the TUI. +Reviews and approves blocked agent network requests in the TUI. Use when approving or denying sandbox egress requests, managing blocked network calls, or using the approval TUI. ## Prerequisites diff --git a/.agents/skills/nemoclaw-monitor-sandbox/SKILL.md b/.agents/skills/nemoclaw-monitor-sandbox/SKILL.md index 70110e52a..728fc883a 100644 --- a/.agents/skills/nemoclaw-monitor-sandbox/SKILL.md +++ b/.agents/skills/nemoclaw-monitor-sandbox/SKILL.md @@ -1,11 +1,11 @@ --- name: nemoclaw-monitor-sandbox -description: Inspects sandbox health, trace agent behavior, and diagnose problems. Use when debug nemoclaw agent issues, monitor nemoclaw sandbox, monitor nemoclaw sandbox activity, monitoring, nemoclaw, openclaw, openshell, troubleshooting. +description: Inspects sandbox health, traces agent behavior, and diagnoses problems. Use when monitoring a running sandbox, debugging agent issues, or checking sandbox logs. --- -# Nemoclaw Monitor Sandbox +# NemoClaw Monitor Sandbox -Inspect sandbox health, trace agent behavior, and diagnose problems. +Inspects sandbox health, traces agent behavior, and diagnoses problems. Use when monitoring a running sandbox, debugging agent issues, or checking sandbox logs. ## Prerequisites @@ -28,7 +28,8 @@ Key fields in the output include the following: - Blueprint run ID, which is the identifier for the most recent blueprint execution. - Inference provider, which shows the active provider, model, and endpoint. -Run `nemoclaw status` on the host to check sandbox state. Use `openshell sandbox list` for the underlying sandbox details. +Run `nemoclaw status` on the host to check sandbox state. +Use `openshell sandbox list` for the underlying sandbox details. ## Step 2: View Blueprint and Sandbox Logs @@ -41,7 +42,7 @@ $ nemoclaw logs To follow the log output in real time: ```console -$ nemoclaw logs -f +$ nemoclaw logs --follow ``` ## Step 3: Monitor Network Activity in the TUI @@ -74,7 +75,7 @@ $ openclaw agent --agent main --local -m "Test inference" --session-id debug If the request fails, check the following: 1. Run `nemoclaw status` to confirm the active provider and endpoint. -2. Run `nemoclaw logs -f` to view error messages from the blueprint runner. +2. Run `nemoclaw logs --follow` to view error messages from the blueprint runner. 3. Verify that the inference endpoint is reachable from the host. ## Related Skills diff --git a/.agents/skills/nemoclaw-overview/SKILL.md b/.agents/skills/nemoclaw-overview/SKILL.md index f7ddd90a5..247fe07f6 100644 --- a/.agents/skills/nemoclaw-overview/SKILL.md +++ b/.agents/skills/nemoclaw-overview/SKILL.md @@ -1,11 +1,11 @@ --- name: nemoclaw-overview -description: Learns how NemoClaw combines a lightweight CLI plugin with a versioned blueprint to move OpenClaw into a controlled sandbox. Also covers nemoClaws is an open source reference stack that simplifies running OpenClaw always-on assistants safely; changelogs and feature history for NemoClaw releases. Use when blueprints, how nemoclaw works, inference routing, nemoclaw, nemoclaw changelog, nemoclaw overview, nemoclaw overview does fits, nemoclaw release notes. +description: Describes how NemoClaw combines a CLI plugin with a versioned blueprint to move OpenClaw into a controlled sandbox. Use when explaining the sandbox lifecycle, blueprint architecture, or how NemoClaw layers on top of OpenShell. Explains what NemoClaw does and how it fits with OpenClaw and OpenShell. Use when asking what NemoClaw is, how it works at a high level, or what the project provides. Lists changelogs and feature history for NemoClaw releases. Use when checking what changed in a release, looking up version history, or reviewing the changelog. --- -# Nemoclaw Overview +# NemoClaw Overview -Learn how NemoClaw combines a lightweight CLI plugin with a versioned blueprint to move OpenClaw into a controlled sandbox. +Describes how NemoClaw combines a CLI plugin with a versioned blueprint to move OpenClaw into a controlled sandbox. Use when explaining the sandbox lifecycle, blueprint architecture, or how NemoClaw layers on top of OpenShell. ## Context @@ -17,6 +17,17 @@ This page explains the key concepts about NemoClaw at a high level. The `nemoclaw` CLI is the primary entrypoint for setting up and managing sandboxed OpenClaw agents. It delegates heavy lifting to a versioned blueprint, a Python artifact that orchestrates sandbox creation, policy application, and inference provider setup through the OpenShell CLI. +NemoClaw adds the following layers on top of OpenShell. + +| Layer | What it provides | +|-------|------------------| +| Onboarding | Guided setup that validates credentials, selects providers, and creates a working sandbox in one command. | +| Blueprint | A hardened Dockerfile with security policies, capability drops, and least-privilege network rules. | +| State management | Safe migration of agent state across machines with credential stripping and integrity verification. | +| Messaging bridges | Host-side processes that connect Telegram, Discord, and Slack to the sandboxed agent. | + +OpenShell handles *how* to sandbox an agent securely. NemoClaw handles *what* goes in the sandbox and makes the setup accessible. For the full system diagram, see Architecture (see the `nemoclaw-reference` skill). + ```mermaid flowchart TB subgraph Host @@ -58,24 +69,8 @@ flowchart TB ## Design Principles -NemoClaw architecture follows the following principles. - -Thin plugin, versioned blueprint -: The plugin stays small and stable. Orchestration logic lives in the blueprint and evolves on its own release cadence. - -Respect CLI boundaries -: The `nemoclaw` CLI is the primary interface for sandbox management. - -Supply chain safety -: Blueprint artifacts are immutable, versioned, and digest-verified before execution. - *Full details in `references/how-it-works.md`.* -> **Alpha software:** NemoClaw is in alpha, available as an early preview since March 16, 2026. -> APIs, configuration schemas, and runtime behavior are subject to breaking changes between releases. -> Do not use this software in production environments. -> File issues and feedback through the GitHub repository as the project continues to stabilize. - NVIDIA NemoClaw is an open source reference stack that simplifies running [OpenClaw](https://openclaw.ai) always-on assistants. It incorporates policy-based privacy and security guardrails, giving users control over their agents’ behavior and data handling. This enables self-evolving claws to run more safely in clouds, on prem, RTX PCs and DGX Spark. @@ -89,6 +84,19 @@ By combining powerful open source models with built-in safety measures, NemoClaw | Route inference | Configures OpenShell inference routing so agent traffic flows through cloud-hosted Nemotron 3 Super 120B via [build.nvidia.com](https://build.nvidia.com). | | Manage the lifecycle | Handles blueprint versioning, digest verification, and sandbox setup. | +## Key Features + +NemoClaw provides the following capabilities on top of the OpenShell runtime. + +| Feature | Description | +|---------|-------------| +| Guided onboarding | Validates credentials, selects providers, and creates a working sandbox in one command. | +| Hardened blueprint | A security-first Dockerfile with capability drops, least-privilege network rules, and declarative policy. | +| State management | Safe migration of agent state across machines with credential stripping and integrity verification. | +| Messaging bridges | Host-side processes that connect Telegram, Discord, and Slack to the sandboxed agent. | +| Routed inference | Provider-routed model calls through the OpenShell gateway, transparent to the agent. Supports NVIDIA Endpoints, OpenAI, Anthropic, Google Gemini, and local Ollama. | +| Layered protection | Network, filesystem, process, and inference controls that can be hot-reloaded or locked at creation. | + ## Challenge Autonomous AI agents like OpenClaw can make arbitrary network requests, access the host filesystem, and call any inference endpoint. Without guardrails, this creates security, cost, and compliance risks that grow as agents run unattended. @@ -115,6 +123,8 @@ You can use NemoClaw for various use cases including the following. | Sandboxed testing | Test agent behavior in a locked-down environment before granting broader permissions. | | Remote GPU deployment | Deploy a sandboxed agent to a remote GPU instance for persistent operation. | +*Full details in `references/overview.md`.* + ## Reference - [NemoClaw Release Notes](references/release-notes.md) @@ -124,5 +134,3 @@ You can use NemoClaw for various use cases including the following. - `nemoclaw-get-started` — Quickstart to install NemoClaw and run your first agent - `nemoclaw-configure-inference` — Switch Inference Providers to configure the inference provider - `nemoclaw-manage-policy` — Approve or Deny Network Requests to manage egress approvals -- `nemoclaw-deploy-remote` — Deploy to a Remote GPU Instance for persistent operation -- `nemoclaw-monitor-sandbox` — Monitor Sandbox Activity to observe agent behavior diff --git a/.agents/skills/nemoclaw-overview/references/how-it-works.md b/.agents/skills/nemoclaw-overview/references/how-it-works.md index 253af7ed0..535aeff12 100644 --- a/.agents/skills/nemoclaw-overview/references/how-it-works.md +++ b/.agents/skills/nemoclaw-overview/references/how-it-works.md @@ -8,6 +8,17 @@ This page explains the key concepts about NemoClaw at a high level. The `nemoclaw` CLI is the primary entrypoint for setting up and managing sandboxed OpenClaw agents. It delegates heavy lifting to a versioned blueprint, a Python artifact that orchestrates sandbox creation, policy application, and inference provider setup through the OpenShell CLI. +NemoClaw adds the following layers on top of OpenShell. + +| Layer | What it provides | +|-------|------------------| +| Onboarding | Guided setup that validates credentials, selects providers, and creates a working sandbox in one command. | +| Blueprint | A hardened Dockerfile with security policies, capability drops, and least-privilege network rules. | +| State management | Safe migration of agent state across machines with credential stripping and integrity verification. | +| Messaging bridges | Host-side processes that connect Telegram, Discord, and Slack to the sandboxed agent. | + +OpenShell handles *how* to sandbox an agent securely. NemoClaw handles *what* goes in the sandbox and makes the setup accessible. For the full system diagram, see Architecture (see the `nemoclaw-reference` skill). + ```mermaid flowchart TB subgraph Host @@ -96,20 +107,23 @@ OpenShell intercepts every inference call and routes it to the configured provid During onboarding, NemoClaw validates the selected provider and model, configures the OpenShell route, and bakes the matching model reference into the sandbox image. The sandbox then talks to `inference.local`, while the host owns the actual provider credential and upstream endpoint. -## Network and Filesystem Policy +## Protection Layers + +The sandbox starts with a default policy that controls network egress, filesystem access, process privileges, and inference routing. -The sandbox starts with a default policy defined in `openclaw-sandbox.yaml`. -This policy controls which network endpoints the agent can reach and which filesystem paths it can access. +| Layer | What it protects | When it applies | +|---|---|---| +| Network | Blocks unauthorized outbound connections. | Hot-reloadable at runtime. | +| Filesystem | Prevents reads and writes outside `/sandbox` and `/tmp`. | Locked at sandbox creation. | +| Process | Blocks privilege escalation and dangerous syscalls. | Locked at sandbox creation. | +| Inference | Reroutes model API calls to controlled backends. | Hot-reloadable at runtime. | -- For network, only endpoints listed in the policy are allowed. - When the agent tries to reach an unlisted host, OpenShell blocks the request and surfaces it in the TUI for operator approval. -- For filesystem, the agent can write to `/sandbox` and `/tmp`. - All other system paths are read-only. +When the agent tries to reach an unlisted host, OpenShell blocks the request and surfaces it in the TUI for operator approval. Approved endpoints persist for the current session but are not saved to the baseline policy file. -Approved endpoints persist for the current session but are not saved to the baseline policy file. +For details on the baseline rules, refer to Network Policies (see the `nemoclaw-reference` skill). For container-level hardening, refer to Sandbox Hardening (see the `nemoclaw-deploy-remote` skill). ## Next Steps - Follow the Quickstart (see the `nemoclaw-get-started` skill) to launch your first sandbox. - Refer to the Architecture (see the `nemoclaw-reference` skill) for the full technical structure, including file layouts and the blueprint lifecycle. -- Refer to Inference Profiles (see the `nemoclaw-reference` skill) for detailed provider configuration. +- Refer to Inference Options (see the `nemoclaw-configure-inference` skill) for detailed provider configuration. diff --git a/.agents/skills/nemoclaw-overview/references/overview.md b/.agents/skills/nemoclaw-overview/references/overview.md index 71203b472..0d9a7c36c 100644 --- a/.agents/skills/nemoclaw-overview/references/overview.md +++ b/.agents/skills/nemoclaw-overview/references/overview.md @@ -1,10 +1,5 @@ # Overview -> **Alpha software:** NemoClaw is in alpha, available as an early preview since March 16, 2026. -> APIs, configuration schemas, and runtime behavior are subject to breaking changes between releases. -> Do not use this software in production environments. -> File issues and feedback through the GitHub repository as the project continues to stabilize. - NVIDIA NemoClaw is an open source reference stack that simplifies running [OpenClaw](https://openclaw.ai) always-on assistants. It incorporates policy-based privacy and security guardrails, giving users control over their agents’ behavior and data handling. This enables self-evolving claws to run more safely in clouds, on prem, RTX PCs and DGX Spark. @@ -18,6 +13,19 @@ By combining powerful open source models with built-in safety measures, NemoClaw | Route inference | Configures OpenShell inference routing so agent traffic flows through cloud-hosted Nemotron 3 Super 120B via [build.nvidia.com](https://build.nvidia.com). | | Manage the lifecycle | Handles blueprint versioning, digest verification, and sandbox setup. | +## Key Features + +NemoClaw provides the following capabilities on top of the OpenShell runtime. + +| Feature | Description | +|---------|-------------| +| Guided onboarding | Validates credentials, selects providers, and creates a working sandbox in one command. | +| Hardened blueprint | A security-first Dockerfile with capability drops, least-privilege network rules, and declarative policy. | +| State management | Safe migration of agent state across machines with credential stripping and integrity verification. | +| Messaging bridges | Host-side processes that connect Telegram, Discord, and Slack to the sandboxed agent. | +| Routed inference | Provider-routed model calls through the OpenShell gateway, transparent to the agent. Supports NVIDIA Endpoints, OpenAI, Anthropic, Google Gemini, and local Ollama. | +| Layered protection | Network, filesystem, process, and inference controls that can be hot-reloaded or locked at creation. | + ## Challenge Autonomous AI agents like OpenClaw can make arbitrary network requests, access the host filesystem, and call any inference endpoint. Without guardrails, this creates security, cost, and compliance risks that grow as agents run unattended. diff --git a/.agents/skills/nemoclaw-overview/references/release-notes.md b/.agents/skills/nemoclaw-overview/references/release-notes.md index 92949fafe..ea482162c 100644 --- a/.agents/skills/nemoclaw-overview/references/release-notes.md +++ b/.agents/skills/nemoclaw-overview/references/release-notes.md @@ -1,10 +1,5 @@ # Release Notes -> **Alpha software:** NemoClaw is in alpha, available as an early preview since March 16, 2026. -> APIs, configuration schemas, and runtime behavior are subject to breaking changes between releases. -> Do not use this software in production environments. -> File issues and feedback through the GitHub repository as the project continues to stabilize. - NVIDIA NemoClaw is available in early preview starting March 16, 2026. Use the following GitHub resources to track changes. | Resource | Description | diff --git a/.agents/skills/nemoclaw-reference/SKILL.md b/.agents/skills/nemoclaw-reference/SKILL.md index 09d2e8293..55f457431 100644 --- a/.agents/skills/nemoclaw-reference/SKILL.md +++ b/.agents/skills/nemoclaw-reference/SKILL.md @@ -1,16 +1,15 @@ --- name: nemoclaw-reference -description: Learns how NemoClaw combines a lightweight CLI plugin with a versioned blueprint to move OpenClaw into a controlled sandbox. Also covers fulls CLI reference for plugin and standalone NemoClaw commands; configurations reference for NemoClaw routed inference providers. Use when blueprints, cli, inference routing, llms, nemoclaw, nemoclaw architecture, nemoclaw architecture plugin blueprint, nemoclaw cli commands. +description: Describes how NemoClaw combines a CLI plugin with a versioned blueprint to move OpenClaw into a controlled sandbox. Use when looking up NemoClaw architecture, plugin structure, or blueprint design. Lists all slash commands and standalone NemoClaw CLI commands. Use when looking up a command, checking command syntax, or browsing the CLI reference. Documents baseline network policy, filesystem rules, and operator approval flow. Use when reviewing default network policies, understanding egress controls, or looking up the approval flow. Diagnoses and resolves common NemoClaw installation, onboarding, and runtime issues. Use when troubleshooting errors, debugging sandbox problems, or resolving setup failures. --- -# Nemoclaw Reference +# NemoClaw Reference -Learn how NemoClaw combines a lightweight CLI plugin with a versioned blueprint to move OpenClaw into a controlled sandbox. +Describes how NemoClaw combines a CLI plugin with a versioned blueprint to move OpenClaw into a controlled sandbox. Use when looking up NemoClaw architecture, plugin structure, or blueprint design. ## Reference - [NemoClaw Architecture — Plugin, Blueprint, and Sandbox Structure](references/architecture.md) - [NemoClaw CLI Commands Reference](references/commands.md) -- [NemoClaw Inference Profiles](references/inference-profiles.md) - [NemoClaw Network Policies — Baseline Rules and Operator Approval](references/network-policies.md) - [NemoClaw Troubleshooting Guide](references/troubleshooting.md) diff --git a/.agents/skills/nemoclaw-reference/references/architecture.md b/.agents/skills/nemoclaw-reference/references/architecture.md index 9aacf2674..1fcfb8435 100644 --- a/.agents/skills/nemoclaw-reference/references/architecture.md +++ b/.agents/skills/nemoclaw-reference/references/architecture.md @@ -2,6 +2,67 @@ NemoClaw has two main components: a TypeScript plugin that integrates with the OpenClaw CLI, and a Python blueprint that orchestrates OpenShell resources. +## System Overview + +NVIDIA OpenShell is a general-purpose agent runtime. It provides sandbox containers, a credential-storing gateway, inference proxying, and policy enforcement, but has no opinions about what runs inside. NemoClaw is an opinionated reference stack built on OpenShell that handles what goes in the sandbox and makes the setup accessible. + +```mermaid +graph LR + classDef nemoclaw fill:#76b900,stroke:#5a8f00,color:#fff,stroke-width:2px,font-weight:bold + classDef openshell fill:#1a1a1a,stroke:#1a1a1a,color:#fff,stroke-width:2px,font-weight:bold + classDef sandbox fill:#444,stroke:#76b900,color:#fff,stroke-width:2px,font-weight:bold + classDef agent fill:#f5f5f5,stroke:#e0e0e0,color:#1a1a1a,stroke-width:1px + classDef external fill:#f5f5f5,stroke:#e0e0e0,color:#1a1a1a,stroke-width:1px + classDef user fill:#fff,stroke:#76b900,color:#1a1a1a,stroke-width:2px,font-weight:bold + + USER(["👤 User"]):::user + + subgraph EXTERNAL["External Services"] + INFERENCE["Inference Provider
NVIDIA Endpoints · OpenAI
Anthropic · Ollama · vLLM
"]:::external + MSGAPI["Messaging Platforms
Telegram · Discord · Slack"]:::external + INTERNET["Internet
PyPI · npm · GitHub · APIs"]:::external + end + + subgraph HOST["Host Machine"] + + subgraph NEMOCLAW["NemoClaw"] + direction TB + NCLI["CLI + Onboarding
Guided setup · provider selection
credential validation · deploy
"]:::nemoclaw + BRIDGE["Messaging Bridges
Connect chat platforms
to sandboxed agent
"]:::nemoclaw + BP["Blueprint
Hardened Dockerfile
Network policies · Presets
Security configuration
"]:::nemoclaw + MIGRATE["State Management
Migration snapshots
Credential stripping
Integrity verification
"]:::nemoclaw + end + + subgraph OPENSHELL["OpenShell"] + direction TB + GW["Gateway
Credential store
Inference proxy
Policy engine
Device auth
"]:::openshell + OSCLI["openshell CLI
provider · sandbox
gateway · policy
"]:::openshell + + subgraph SANDBOX["Sandbox Container 🔒"] + direction TB + AGENT["Agent
OpenClaw or any
compatible agent
"]:::agent + PLUG["NemoClaw Plugin
Extends agent with
managed configuration
"]:::sandbox + end + end + end + + USER -->|"nemoclaw onboard
nemoclaw connect"| NCLI + USER -->|"Chat messages"| MSGAPI + + NCLI -->|"Orchestrates"| OSCLI + BP -->|"Defines sandbox
shape + policies"| SANDBOX + MIGRATE -->|"Safe state
transfer"| SANDBOX + + AGENT -->|"Inference requests
no credentials"| GW + GW -->|"Proxied with
credential injected"| INFERENCE + + MSGAPI -->|"Bot messages"| BRIDGE + BRIDGE -->|"Relayed as data
via SSH"| AGENT + + AGENT -.->|"Policy-gated"| INTERNET + GW -.->|"Enforced by
gateway"| INTERNET +``` + ## NemoClaw Plugin The plugin is a thin TypeScript package that registers an inference provider and the `/nemoclaw` slash command. @@ -87,4 +148,24 @@ OpenShell intercepts them and routes to the configured provider: Agent (sandbox) ──▶ OpenShell gateway ──▶ NVIDIA Endpoint (build.nvidia.com) ``` -Refer to Inference Profiles (see the `nemoclaw-reference` skill) for provider configuration details. +Refer to Inference Options (see the `nemoclaw-configure-inference` skill) for provider configuration details. + +## Host-Side State and Config + +NemoClaw keeps its operator-facing state on the host rather than inside the sandbox. + +| Path | Purpose | +|---|---| +| `~/.nemoclaw/credentials.json` | Provider credentials saved during onboarding. | +| `~/.nemoclaw/sandboxes.json` | Registered sandbox metadata, including the default sandbox selection. | +| `~/.openclaw/openclaw.json` | Host OpenClaw configuration that NemoClaw snapshots or restores during migration flows. | + +The following environment variables configure optional services and local access. + +| Variable | Purpose | +|---|---| +| `TELEGRAM_BOT_TOKEN` | Bot token for the Telegram bridge. | +| `ALLOWED_CHAT_IDS` | Comma-separated list of Telegram chat IDs allowed to message the agent. | +| `CHAT_UI_URL` | URL for the optional chat UI endpoint. | + +For normal setup and reconfiguration, prefer `nemoclaw onboard` over editing these files by hand. diff --git a/.agents/skills/nemoclaw-reference/references/commands.md b/.agents/skills/nemoclaw-reference/references/commands.md index d43501ce5..15dbe9068 100644 --- a/.agents/skills/nemoclaw-reference/references/commands.md +++ b/.agents/skills/nemoclaw-reference/references/commands.md @@ -8,15 +8,35 @@ The `/nemoclaw` slash command is available inside the OpenClaw chat interface fo | Subcommand | Description | |---|---| +| `/nemoclaw` | Show slash-command help and host CLI pointers | | `/nemoclaw status` | Show sandbox and inference state | +| `/nemoclaw onboard` | Show onboarding status and reconfiguration guidance | +| `/nemoclaw eject` | Show rollback instructions for returning to the host installation | ## Standalone Host Commands The `nemoclaw` binary handles host-side operations that run outside the OpenClaw plugin context. +### `nemoclaw help`, `nemoclaw --help`, `nemoclaw -h` + +Show the top-level usage summary and command groups. +Running `nemoclaw` with no arguments shows the same help output. + +```console +$ nemoclaw help +``` + +### `nemoclaw --version`, `nemoclaw -v` + +Print the installed NemoClaw CLI version. + +```console +$ nemoclaw --version +``` + ### `nemoclaw onboard` -Run the interactive setup wizard. +Run the interactive setup wizard (recommended for new installs). The wizard creates an OpenShell gateway, registers inference providers, builds the sandbox image, and creates the sandbox. Use this command for new installs and for recreating a sandbox after changes to policy or configuration. @@ -27,6 +47,19 @@ $ nemoclaw onboard The wizard prompts for a provider first, then collects the provider credential if needed. Supported non-experimental choices include NVIDIA Endpoints, OpenAI, Anthropic, Google Gemini, and compatible OpenAI or Anthropic endpoints. Credentials are stored in `~/.nemoclaw/credentials.json`. +The legacy `nemoclaw setup` command is deprecated; use `nemoclaw onboard` instead. + +For non-interactive onboarding, you must explicitly accept the third-party software notice: + +```console +$ nemoclaw onboard --non-interactive --yes-i-accept-third-party-software +``` + +or: + +```console +$ NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 nemoclaw onboard --non-interactive +``` The wizard prompts for a sandbox name. Names must follow RFC 1123 subdomain rules: lowercase alphanumeric characters and hyphens only, and must start and end with an alphanumeric character. @@ -48,7 +81,7 @@ $ nemoclaw list > **Warning:** The `nemoclaw deploy` command is experimental and may not work as expected. Deploy NemoClaw to a remote GPU instance through [Brev](https://brev.nvidia.com). -The deploy script installs Docker, NVIDIA Container Toolkit if a GPU is present, and OpenShell on the VM, then runs the nemoclaw setup and connects to the sandbox. +The deploy script installs Docker, NVIDIA Container Toolkit if a GPU is present, and OpenShell on the VM, then runs `nemoclaw onboard` and connects to the sandbox. ```console $ nemoclaw deploy @@ -156,3 +189,43 @@ After the fixes complete, the script prompts you to run `nemoclaw onboard` to co ```console $ sudo nemoclaw setup-spark ``` + +### `nemoclaw debug` + +Collect diagnostics for bug reports. +Gathers system info, Docker state, gateway logs, and sandbox status into a summary or tarball. +Use `--sandbox ` to target a specific sandbox, `--quick` for a smaller snapshot, or `--output ` to save a tarball that you can attach to an issue. + +```console +$ nemoclaw debug [--quick] [--sandbox NAME] [--output PATH] +``` + +| Flag | Description | +|------|-------------| +| `--quick` | Collect minimal diagnostics only | +| `--sandbox NAME` | Target a specific sandbox (default: auto-detect) | +| `--output PATH` | Write diagnostics tarball to the given path | + +### `nemoclaw uninstall` + +Run `uninstall.sh` to remove NemoClaw sandboxes, gateway resources, related images and containers, and local state. +The CLI uses the local `uninstall.sh` first and falls back to the hosted script if the local file is unavailable. + +| Flag | Effect | +|---|---| +| `--yes` | Skip the confirmation prompt | +| `--keep-openshell` | Leave the `openshell` binary installed | +| `--delete-models` | Also remove NemoClaw-pulled Ollama models | + +```console +$ nemoclaw uninstall [--yes] [--keep-openshell] [--delete-models] +``` + +### Legacy `nemoclaw setup` + +Deprecated. Use `nemoclaw onboard` instead. +Running `nemoclaw setup` now delegates directly to `nemoclaw onboard`. + +```console +$ nemoclaw setup +``` diff --git a/.agents/skills/nemoclaw-reference/references/inference-profiles.md b/.agents/skills/nemoclaw-reference/references/inference-profiles.md index b2f141c18..2920cdbe5 100644 --- a/.agents/skills/nemoclaw-reference/references/inference-profiles.md +++ b/.agents/skills/nemoclaw-reference/references/inference-profiles.md @@ -44,13 +44,10 @@ NemoClaw validates the selected provider and model before it creates the sandbox If validation fails, the wizard does not continue to sandbox creation. -## Local Providers +## Local Ollama -Local providers use the same routed `inference.local` pattern, but the upstream runtime runs on the host rather than in the cloud. - -- Local Ollama -- Local NVIDIA NIM -- Local vLLM +Local Ollama is available in the standard onboarding flow when Ollama is installed or running on the host. +It uses the same routed `inference.local` pattern, but the upstream runtime runs locally instead of in the cloud. Ollama gets additional onboarding help: @@ -59,6 +56,23 @@ Ollama gets additional onboarding help: - it warms the model - it validates the model before continuing +On Linux hosts that run NemoClaw with Docker, the sandbox reaches Ollama through `http://host.openshell.internal:11434`, not the host shell's `localhost` socket. +If Ollama is already running, make sure it listens on `0.0.0.0:11434` instead of `127.0.0.1:11434`. +Run the following command to start Ollama with that bind address. + +```console +$ OLLAMA_HOST=0.0.0.0:11434 ollama serve +``` + +If Ollama only binds loopback, NemoClaw can detect it on the host, but the sandbox-side validation step fails because containers cannot reach it. + +## Experimental Local Providers + +The following local providers require `NEMOCLAW_EXPERIMENTAL=1`: + +- Local NVIDIA NIM (requires a NIM-capable GPU) +- Local vLLM (must already be running on `localhost:8000`) + ## Runtime Switching For runtime switching guidance, refer to Switch Inference Models (see the `nemoclaw-configure-inference` skill). diff --git a/.agents/skills/nemoclaw-reference/references/network-policies.md b/.agents/skills/nemoclaw-reference/references/network-policies.md index a32e4158d..7e895cf43 100644 --- a/.agents/skills/nemoclaw-reference/references/network-policies.md +++ b/.agents/skills/nemoclaw-reference/references/network-policies.md @@ -52,13 +52,13 @@ The following endpoint groups are allowed by default: - GET, POST, PATCH, PUT, DELETE * - `clawhub` - - `clawhub.com:443` - - `/usr/local/bin/openclaw` + - `clawhub.ai:443` + - `/usr/local/bin/openclaw`, `/usr/local/bin/node` - GET, POST * - `openclaw_api` - `openclaw.ai:443` - - `/usr/local/bin/openclaw` + - `/usr/local/bin/openclaw`, `/usr/local/bin/node` - GET, POST * - `openclaw_docs` @@ -68,8 +68,8 @@ The following endpoint groups are allowed by default: * - `npm_registry` - `registry.npmjs.org:443` - - `/usr/local/bin/openclaw`, `/usr/local/bin/npm` - - GET only + - `/usr/local/bin/openclaw`, `/usr/local/bin/npm`, `/usr/local/bin/node` + - All methods, all paths * - `telegram` - `api.telegram.org:443` diff --git a/.agents/skills/nemoclaw-reference/references/troubleshooting.md b/.agents/skills/nemoclaw-reference/references/troubleshooting.md index 12dd07c9e..b0677a813 100644 --- a/.agents/skills/nemoclaw-reference/references/troubleshooting.md +++ b/.agents/skills/nemoclaw-reference/references/troubleshooting.md @@ -21,23 +21,29 @@ If you see an unsupported platform error, verify that you are running on a suppo ### Node.js version is too old -NemoClaw requires Node.js 20 or later. +NemoClaw requires Node.js 22.16 or later. If the installer exits with a Node.js version error, check your current version: ```console $ node --version ``` -If the version is below 20, install a supported release. +If the version is below 22.16, install a supported release. If you use nvm, run: ```console -$ nvm install 20 -$ nvm use 20 +$ nvm install 22 +$ nvm use 22 ``` Then re-run the installer. +### Image push fails with out-of-memory errors + +The sandbox image is approximately 2.4 GB compressed. During image push, the Docker daemon, k3s, and the OpenShell gateway run alongside the export pipeline, which buffers decompressed layers in memory. On machines with less than 8 GB of RAM, this combined usage can trigger the OOM killer. + +If you cannot add memory, configure at least 8 GB of swap to work around the issue at the cost of slower performance. + ### Docker is not running The installer and onboard wizard require Docker to be running. @@ -49,6 +55,15 @@ $ sudo systemctl start docker On macOS with Docker Desktop, open the Docker Desktop application and wait for it to finish starting before retrying. +### macOS first-run failures + +The two most common first-run failures on macOS are missing developer tools and Docker connection errors. + +To avoid these issues, install the prerequisites in the following order before running the NemoClaw installer: + +1. Install Xcode Command Line Tools (`xcode-select --install`). These are needed by the installer and Node.js toolchain. +2. Install and start a supported container runtime (Docker Desktop or Colima). Without a running runtime, the installer cannot connect to Docker. + ### npm install fails with permission errors If `npm install` fails with an `EACCES` permission error, do not run npm with `sudo`. @@ -69,7 +84,7 @@ If another process is already bound to this port, onboarding fails. Identify the conflicting process, verify it is safe to stop, and terminate it: ```console -$ lsof -i :18789 +$ sudo lsof -i :18789 $ kill ``` @@ -115,8 +130,70 @@ If neither is found, verify that Colima is running: $ colima status ``` +### Sandbox creation killed by OOM (exit 137) + +On systems with 8 GB RAM or less and no swap configured, the sandbox image push can exhaust available memory and get killed by the Linux OOM killer (exit code 137). + +NemoClaw automatically detects low memory during onboarding and prompts to create a 4 GB swap file. +If this automatic step fails or you are using a custom setup flow, create swap manually before running `nemoclaw onboard`: + +```console +$ sudo dd if=/dev/zero of=/swapfile bs=1M count=4096 status=none +$ sudo chmod 600 /swapfile +$ sudo mkswap /swapfile +$ sudo swapon /swapfile +$ echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab +$ nemoclaw onboard +``` + ## Runtime +### Reconnect after a host reboot + +After a host reboot, the container runtime, OpenShell gateway, and sandbox may not be running. +Follow these steps to reconnect. + +1. Start the container runtime. + + - **Linux:** start Docker if it is not already running (`sudo systemctl start docker`) + - **macOS:** open Docker Desktop or start Colima (`colima start`) + +1. Check sandbox state. + + ```console + $ openshell sandbox list + ``` + + If the sandbox shows `Ready`, skip to step 4. + +1. Restart the gateway (if needed). + + If the sandbox is not listed or the command fails, restart the OpenShell gateway: + + ```console + $ openshell gateway start --name nemoclaw + ``` + + Wait a few seconds, then re-check with `openshell sandbox list`. + +1. Reconnect. + + ```console + $ nemoclaw connect + ``` + +1. Start auxiliary services (if needed). + + If you use the Telegram bridge or cloudflared tunnel, start them again: + + ```console + $ nemoclaw start + ``` + +> **If the sandbox does not recover:** If the sandbox remains missing after restarting the gateway, run `nemoclaw onboard` to recreate it. +> The wizard prompts for confirmation before destroying an existing sandbox. If you confirm, it **destroys and recreates** the sandbox — workspace files (SOUL.md, USER.md, IDENTITY.md, AGENTS.md, MEMORY.md, and daily memory notes) are lost. +> Back up your workspace first by following the instructions at Back Up and Restore (see the `nemoclaw-workspace` skill). + ### Sandbox shows as stopped The sandbox may have been stopped or deleted. diff --git a/.agents/skills/nemoclaw-security-best/SKILL.md b/.agents/skills/nemoclaw-security-best/SKILL.md new file mode 100644 index 000000000..9964e07b3 --- /dev/null +++ b/.agents/skills/nemoclaw-security-best/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nemoclaw-security-best +description: Presents a risk framework for every configurable security control in NemoClaw. Use when evaluating security posture, reviewing sandbox security defaults, or assessing control trade-offs. +--- + +# NemoClaw Security Best + +Presents a risk framework for every configurable security control in NemoClaw. Use when evaluating security posture, reviewing sandbox security defaults, or assessing control trade-offs. + +## Context + +NemoClaw ships with deny-by-default security controls across four layers: network, filesystem, process, and inference. +You can tune every control, but each change shifts the risk profile. +This page documents every configurable knob, its default, what it protects, the concrete risk of relaxing it, and a recommendation for common use cases. + +For background on how the layers fit together, refer to How It Works (see the `nemoclaw-overview` skill). + + + +## Protection Layers at a Glance + +NemoClaw enforces security at four layers. +NemoClaw locks some when it creates the sandbox and requires a restart to change them. +You can hot-reload others while the sandbox runs. + +The following diagram shows the default posture immediately after `nemoclaw onboard`, before you approve any endpoints or apply any presets. + +```mermaid +flowchart TB + subgraph HOST["Your Machine — default posture after nemoclaw onboard"] + direction TB + + YOU["👤 Operator"] + + subgraph NC["NemoClaw + OpenShell"] + direction TB + + subgraph SB["Sandbox — the agent's isolated world"] + direction LR + PROC["⚙️ Process Layer
Controls what the agent can execute"] + FS["📁 Filesystem Layer
Controls what the agent can read and write"] + AGENT["🤖 Agent"] + end + + subgraph GW["Gateway — the gatekeeper"] + direction LR + NET["🌐 Network Layer
Controls where the agent can connect"] + INF["🧠 Inference Layer
Controls which AI models the agent can use"] + end + end + end + + OUTSIDE["🌍 Outside World
Internet · AI Providers · APIs"] + + AGENT -- "all requests" --> GW + GW -- "approved only" --> OUTSIDE + YOU -. "approve / deny" .-> GW + + classDef agent fill:#76b900,stroke:#5a8f00,color:#fff,stroke-width:2px,font-weight:bold + classDef locked fill:#1a1a1a,stroke:#76b900,color:#fff,stroke-width:2px + classDef hot fill:#333,stroke:#76b900,color:#e6f2cc,stroke-width:2px + classDef external fill:#f5f5f5,stroke:#ccc,color:#1a1a1a,stroke-width:1px + classDef operator fill:#fff,stroke:#76b900,color:#1a1a1a,stroke-width:2px,font-weight:bold + + class AGENT agent + class PROC,FS locked + class NET,INF hot + class OUTSIDE external + class YOU operator + + style HOST fill:none,stroke:#76b900,stroke-width:2px,color:#1a1a1a + style NC fill:none,stroke:#76b900,stroke-width:1px,stroke-dasharray:5 5,color:#1a1a1a + style SB fill:#f5faed,stroke:#76b900,stroke-width:2px,color:#1a1a1a + style GW fill:#2a2a2a,stroke:#76b900,stroke-width:2px,color:#fff + +*Full details in `references/best-practices.md`.* diff --git a/.agents/skills/nemoclaw-security-best/references/best-practices.md b/.agents/skills/nemoclaw-security-best/references/best-practices.md new file mode 100644 index 000000000..56d6bf7a2 --- /dev/null +++ b/.agents/skills/nemoclaw-security-best/references/best-practices.md @@ -0,0 +1,487 @@ +# Security Best Practices + +NemoClaw ships with deny-by-default security controls across four layers: network, filesystem, process, and inference. +You can tune every control, but each change shifts the risk profile. +This page documents every configurable knob, its default, what it protects, the concrete risk of relaxing it, and a recommendation for common use cases. + +For background on how the layers fit together, refer to How It Works (see the `nemoclaw-overview` skill). + + + +## Protection Layers at a Glance + +NemoClaw enforces security at four layers. +NemoClaw locks some when it creates the sandbox and requires a restart to change them. +You can hot-reload others while the sandbox runs. + +The following diagram shows the default posture immediately after `nemoclaw onboard`, before you approve any endpoints or apply any presets. + +```mermaid +flowchart TB + subgraph HOST["Your Machine — default posture after nemoclaw onboard"] + direction TB + + YOU["👤 Operator"] + + subgraph NC["NemoClaw + OpenShell"] + direction TB + + subgraph SB["Sandbox — the agent's isolated world"] + direction LR + PROC["⚙️ Process Layer
Controls what the agent can execute"] + FS["📁 Filesystem Layer
Controls what the agent can read and write"] + AGENT["🤖 Agent"] + end + + subgraph GW["Gateway — the gatekeeper"] + direction LR + NET["🌐 Network Layer
Controls where the agent can connect"] + INF["🧠 Inference Layer
Controls which AI models the agent can use"] + end + end + end + + OUTSIDE["🌍 Outside World
Internet · AI Providers · APIs"] + + AGENT -- "all requests" --> GW + GW -- "approved only" --> OUTSIDE + YOU -. "approve / deny" .-> GW + + classDef agent fill:#76b900,stroke:#5a8f00,color:#fff,stroke-width:2px,font-weight:bold + classDef locked fill:#1a1a1a,stroke:#76b900,color:#fff,stroke-width:2px + classDef hot fill:#333,stroke:#76b900,color:#e6f2cc,stroke-width:2px + classDef external fill:#f5f5f5,stroke:#ccc,color:#1a1a1a,stroke-width:1px + classDef operator fill:#fff,stroke:#76b900,color:#1a1a1a,stroke-width:2px,font-weight:bold + + class AGENT agent + class PROC,FS locked + class NET,INF hot + class OUTSIDE external + class YOU operator + + style HOST fill:none,stroke:#76b900,stroke-width:2px,color:#1a1a1a + style NC fill:none,stroke:#76b900,stroke-width:1px,stroke-dasharray:5 5,color:#1a1a1a + style SB fill:#f5faed,stroke:#76b900,stroke-width:2px,color:#1a1a1a + style GW fill:#2a2a2a,stroke:#76b900,stroke-width:2px,color:#fff +``` + +:::{list-table} +:header-rows: 1 +:widths: 20 30 20 30 + +* - Layer + - What it protects + - Enforcement point + - Changeable at runtime + +* - Network + - Unauthorized outbound connections and data exfiltration. + - OpenShell gateway + - Yes. Use `openshell policy set` or operator approval. + +* - Filesystem + - System binary tampering, credential theft, config manipulation. + - Landlock LSM + container mounts + - No. Requires sandbox re-creation. + +* - Process + - Privilege escalation, fork bombs, syscall abuse. + - Container runtime (Docker/K8s `securityContext`) + - No. Requires sandbox re-creation. + +* - Inference + - Credential exposure, unauthorized model access, cost overruns. + - OpenShell gateway + - Yes. Use `openshell inference set`. + +::: + +## Network Controls + +NemoClaw controls which hosts, ports, and HTTP methods the sandbox can reach, and lets operators approve or deny requests in real time. + + + +### Deny-by-Default Egress + +The sandbox blocks all outbound connections unless you explicitly list the endpoint in the policy file `nemoclaw-blueprint/policies/openclaw-sandbox.yaml`. + +| Aspect | Detail | +|---|---| +| Default | All egress denied. Only endpoints in the baseline policy can receive traffic. | +| What you can change | Add endpoints to the policy file (static) or with `openshell policy set` (dynamic). | +| Risk if relaxed | Each allowed endpoint is a potential data exfiltration path. The agent can send workspace content, credentials, or conversation history to any reachable host. | +| Recommendation | Add only endpoints the agent needs for its task. Prefer operator approval for one-off requests over permanently widening the baseline. | + +### Binary-Scoped Endpoint Rules + +Each network policy entry restricts which executables can reach the endpoint using the `binaries` field. + +OpenShell identifies the calling binary by reading `/proc//exe` (the kernel-trusted executable path, not `argv[0]`), walking the process tree for ancestor binaries, and computing a SHA256 hash of each binary on first use. +If someone replaces a binary while the sandbox runs, the hash mismatch triggers an immediate deny. + +| Aspect | Detail | +|---|---| +| Default | Each endpoint restricts access to specific binaries. For example, only `/usr/bin/gh` and `/usr/bin/git` can reach `github.com`. Binary paths support glob patterns (`*` matches one path component, `**` matches recursively). | +| What you can change | Add binaries to an endpoint entry, or omit the `binaries` field to allow any executable. | +| Risk if relaxed | Removing binary restrictions lets any process in the sandbox reach the endpoint. An agent could use `curl`, `wget`, or a Python script to exfiltrate data to an allowed host, bypassing the intended usage pattern. | +| Recommendation | Always scope endpoints to the binaries that need them. If the agent needs a host from a new binary, add that binary explicitly rather than removing the restriction. | + +### Path-Scoped HTTP Rules + +Endpoint rules restrict allowed HTTP methods and URL paths. + +| Aspect | Detail | +|---|---| +| Default | Most endpoints allow GET and POST on `/**`. Some allow GET only (read-only), such as `docs.openclaw.ai`. | +| What you can change | Add methods (PUT, DELETE, PATCH) or restrict paths to specific prefixes. | +| Risk if relaxed | Allowing all methods on an API endpoint gives the agent write and delete access. For example, allowing DELETE on `api.github.com` lets the agent delete repositories. | +| Recommendation | Use GET-only rules for endpoints that the agent only reads. Add write methods only for endpoints where the agent must create or modify resources. Restrict paths to specific API routes when possible. | + +### L4-Only vs L7 Inspection (`protocol` Field) + +All sandbox egress goes through OpenShell's CONNECT proxy. +The `protocol` field on an endpoint controls whether the proxy also inspects individual HTTP requests inside the tunnel. + +| Aspect | Detail | +|---|---| +| Default | Endpoints without a `protocol` field use L4-only enforcement: the proxy checks host, port, and binary identity, then relays the TCP stream without inspecting payloads. Setting `protocol: rest` enables L7 inspection: the proxy auto-detects and terminates TLS, then evaluates each HTTP request's method and path against the endpoint's `rules` or `access` preset. | +| What you can change | Add `protocol: rest` to an endpoint to enable per-request HTTP inspection. Use the `access` preset (`full`, `read-only`, `read-write`) or explicit `rules` to control allowed methods and paths. | +| Risk if relaxed | L4-only endpoints (no `protocol` field) allow the agent to send any data through the tunnel after the initial connection is permitted. The proxy cannot see or filter the HTTP method, path, or body. The `access: full` preset with `protocol: rest` enables inspection but allows all methods and paths, so it does not restrict what the agent can do at the HTTP level. | +| Recommendation | Use `protocol: rest` with specific `rules` for REST APIs where you want method and path control. Use `protocol: rest` with `access: read-only` for read-only endpoints. Omit `protocol` only for non-HTTP protocols (WebSocket, gRPC streaming) or endpoints that do not need HTTP inspection. | + +### Operator Approval Flow + +When the agent reaches an unlisted endpoint, OpenShell blocks the request and prompts the operator in the TUI. + +| Aspect | Detail | +|---|---| +| Default | Enabled. The gateway blocks all unlisted endpoints and requires approval. | +| What you can change | The system merges approved endpoints into the sandbox's policy as a new durable revision. They persist across sandbox restarts within the same sandbox instance. However, when you destroy and recreate the sandbox (for example, by running `nemoclaw onboard`), the policy resets to the baseline defined in the blueprint. | +| Risk if relaxed | Approving an endpoint permanently widens the running sandbox's policy. If you approve a broad domain (such as a CDN that hosts arbitrary content), the agent can fetch anything from that domain until you destroy and recreate the sandbox. | +| Recommendation | Review each blocked request before approving. If you find yourself approving the same endpoint repeatedly, add it to the baseline policy with appropriate binary and path restrictions. To reset approved endpoints, destroy and recreate the sandbox. | + +### Policy Presets + +NemoClaw ships preset policy files in `nemoclaw-blueprint/policies/presets/` for common integrations. + +| Preset | What it enables | Key risk | +|---|---|---| +| `discord` | Discord REST API, WebSocket gateway, CDN. | CDN endpoint (`cdn.discordapp.com`) allows GET to any path. WebSocket uses `access: full` (no inspection). | +| `docker` | Docker Hub, NVIDIA container registry. | Allows pulling arbitrary container images into the sandbox. | +| `huggingface` | Hugging Face model registry. | Allows downloading arbitrary models and datasets. | +| `jira` | Atlassian Jira API. | Gives agent read/write access to project issues and comments. | +| `npm` | npm and Yarn registries. | Allows installing arbitrary npm packages, which may contain malicious code. | +| `outlook` | Microsoft 365, Outlook. | Gives agent access to email. | +| `pypi` | Python Package Index. | Allows installing arbitrary Python packages, which may contain malicious code. | +| `slack` | Slack API, Socket Mode, webhooks. | WebSocket uses `access: full`. Agent can post to any channel the bot token has access to. | +| `telegram` | Telegram Bot API. | Agent can send messages to any chat the bot token has access to. | + +**Recommendation:** Apply presets only when the agent's task requires the integration. Review the preset's YAML file before applying to understand the endpoints, methods, and binary restrictions it adds. + +## Filesystem Controls + +NemoClaw restricts which paths the agent can read and write, protecting system binaries, configuration files, and gateway credentials. + + + +### Read-Only System Paths + +The container mounts system directories read-only to prevent the agent from modifying binaries, libraries, or configuration files. + +| Aspect | Detail | +|---|---| +| Default | `/usr`, `/lib`, `/proc`, `/dev/urandom`, `/app`, `/etc`, `/var/log` are read-only. | +| What you can change | Add or remove paths in the `filesystem_policy.read_only` section of the policy file. | +| Risk if relaxed | Making `/usr` or `/lib` writable lets the agent replace system binaries (such as `curl` or `node`) with trojanized versions. Making `/etc` writable lets the agent modify DNS resolution, TLS trust stores, or user accounts. | +| Recommendation | Never make system paths writable. If the agent needs a writable location for generated files, use a subdirectory of `/sandbox`. | + +### Read-Only `.openclaw` Config + +The `/sandbox/.openclaw` directory contains the OpenClaw gateway configuration, including auth tokens and CORS settings. +The container mounts it read-only while writable agent state (plugins, agent data) lives in `/sandbox/.openclaw-data` through symlinks. + +Multiple defense layers protect this directory: + +- **DAC permissions.** Root owns the directory and `openclaw.json` with `chmod 444`, so the sandbox user cannot write to them. +- **Immutable flag.** The entrypoint applies `chattr +i` to the directory and all symlinks, preventing modification even if other controls fail. +- **Symlink validation.** At startup, the entrypoint verifies every symlink in `.openclaw` points to the expected `.openclaw-data` target. If any symlink points elsewhere, the container refuses to start. +- **Config integrity hash.** The build process pins a SHA256 hash of `openclaw.json`. The entrypoint verifies it at startup and refuses to start if the hash does not match. + +| Aspect | Detail | +|---|---| +| Default | The container mounts `/sandbox/.openclaw` as read-only, root-owned, immutable, and integrity-verified at startup. `/sandbox/.openclaw-data` remains writable. | +| What you can change | Move `/sandbox/.openclaw` from `read_only` to `read_write` in the policy file. | +| Risk if relaxed | A writable `.openclaw` directory lets the agent modify its own gateway config: disabling CORS, changing auth tokens, or redirecting inference to an attacker-controlled endpoint. This is the single most dangerous filesystem change. | +| Recommendation | Never make `/sandbox/.openclaw` writable. | + +### Writable Paths + +The agent has read-write access to `/sandbox`, `/tmp`, and `/dev/null`. + +| Aspect | Detail | +|---|---| +| Default | `/sandbox` (agent workspace), `/tmp` (temporary files), `/dev/null`. | +| What you can change | Add additional writable paths in `filesystem_policy.read_write`. | +| Risk if relaxed | Each additional writable path expands the agent's ability to persist data and potentially modify system behavior. Adding `/var` lets the agent write to log directories. Adding `/home` gives access to other user directories. | +| Recommendation | Keep writable paths to `/sandbox` and `/tmp`. If the agent needs a persistent working directory, create a subdirectory under `/sandbox`. | + +### Landlock LSM Enforcement + +Landlock is a Linux Security Module that enforces filesystem access rules at the kernel level. + +| Aspect | Detail | +|---|---| +| Default | `compatibility: best_effort`. The entrypoint applies Landlock rules when the kernel supports them and silently skips them on older kernels. | +| What you can change | This is a NemoClaw default, not a user-facing knob. | +| Risk if relaxed | On kernels without Landlock support (pre-5.13), filesystem restrictions rely solely on container mount configuration, which is less granular. | +| Recommendation | Run on a kernel that supports Landlock (5.13+). Ubuntu 22.04 LTS and later include Landlock support. | + +## Process Controls + +NemoClaw limits the capabilities, user privileges, and resource quotas available to processes inside the sandbox. + + + +### Capability Drops + +The entrypoint drops dangerous Linux capabilities from the bounding set at startup using `capsh`. +This limits what capabilities any child process (gateway, sandbox, agent) can ever acquire. + +The entrypoint drops these capabilities: `cap_net_raw`, `cap_dac_override`, `cap_sys_chroot`, `cap_fsetid`, `cap_setfcap`, `cap_mknod`, `cap_audit_write`, `cap_net_bind_service`. +The entrypoint keeps these because it needs them for privilege separation using gosu: `cap_chown`, `cap_setuid`, `cap_setgid`, `cap_fowner`, `cap_kill`. + +This is best-effort: if `capsh` is not available or `CAP_SETPCAP` is not in the bounding set, the entrypoint logs a warning and continues with the default capability set. +For additional protection, pass `--cap-drop=ALL` with `docker run` or Compose (see Sandbox Hardening (see the `nemoclaw-deploy-remote` skill)). + +| Aspect | Detail | +|---|---| +| Default | The entrypoint drops dangerous capabilities at startup using `capsh`. Best-effort. | +| What you can change | When launching with `docker run` directly, pass `--cap-drop=ALL --cap-add=NET_BIND_SERVICE` for stricter enforcement. In the standard NemoClaw flow (with `nemoclaw onboard`), the entrypoint handles capability dropping automatically. | +| Risk if relaxed | `CAP_NET_RAW` allows raw socket access for network sniffing. `CAP_DAC_OVERRIDE` bypasses filesystem permission checks. Attackers can use `CAP_SYS_CHROOT` in container escape chains. If `capsh` is unavailable, the container runs with the default Docker capability set. | +| Recommendation | Run on an image that includes `capsh` (the NemoClaw image includes it through `libcap2-bin`). For defense-in-depth, also pass `--cap-drop=ALL` at the container runtime level. | + +### Gateway Process Isolation + +The OpenClaw gateway runs as a separate `gateway` user, not as the `sandbox` user that runs the agent. + +| Aspect | Detail | +|---|---| +| Default | The entrypoint starts the gateway process using `gosu gateway`, isolating it from the agent's `sandbox` user. | +| What you can change | This is not a user-facing knob. The entrypoint enforces it when running as root. In non-root mode (when OpenShell sets `no-new-privileges`), gateway process isolation does not work because `gosu` cannot change users. | +| Risk if relaxed | If the gateway and agent run as the same user, the agent can kill the gateway process and restart it with a tampered configuration (the "fake-HOME" attack). | +| Recommendation | No action needed. The entrypoint handles this automatically. Be aware that non-root mode disables this isolation. | + +### No New Privileges + +The `no-new-privileges` flag prevents processes from gaining additional privileges through setuid binaries or capability inheritance. + +| Aspect | Detail | +|---|---| +| Default | OpenShell sets `PR_SET_NO_NEW_PRIVS` using `prctl()` inside the sandbox process as part of the seccomp filter setup. The NemoClaw Compose example also shows the equivalent `security_opt: no-new-privileges:true` setting. | +| What you can change | OpenShell's seccomp path enforces this inside the sandbox. It is not a user-facing knob. | +| Risk if relaxed | Without this flag, a compromised process could execute a setuid binary to escalate to root inside the container, then attempt container escape techniques. | +| Recommendation | No action needed. OpenShell enforces this automatically when the sandbox network policy is active. This flag prevents `gosu` from switching users, so non-root mode disables gateway process isolation in the NemoClaw entrypoint. | + +### Process Limit + +A process limit caps the number of processes the sandbox user can spawn. +The entrypoint sets both soft and hard limits using `ulimit -u 512`. +This is best-effort: if the container runtime restricts `ulimit` modification, the entrypoint logs a security warning and continues without the limit. + +| Aspect | Detail | +|---|---| +| Default | 512 processes (`ulimit -u 512`), best-effort. | +| What you can change | Increase or decrease the limit with `--ulimit nproc=N:N` in `docker run` or the `ulimits` section in Compose. The runtime-level ulimit takes precedence over the entrypoint's setting. | +| Risk if relaxed | Removing or raising the limit makes the sandbox vulnerable to fork-bomb attacks, where a runaway process spawns children until the host runs out of resources. If the entrypoint cannot set the limit (logs `[SECURITY] Could not set soft/hard nproc limit`), the container runs without process limits. | +| Recommendation | Keep the default at 512. If the agent runs workloads that spawn many child processes (such as parallel test runners), increase to 1024 and monitor host resource usage. If the entrypoint logs a warning about ulimit restrictions, set the limit through the container runtime instead. | + +### Non-Root User + +The sandbox runs agent processes as a dedicated `sandbox` user and group. +The entrypoint starts as root for privilege separation, then drops to the `sandbox` user for all agent commands. + +| Aspect | Detail | +|---|---| +| Default | `run_as_user: sandbox`, `run_as_group: sandbox`. A separate `gateway` user runs the gateway process. | +| What you can change | Change the `process` section in the policy file to run as a different user. | +| Risk if relaxed | Running as `root` inside the container gives the agent access to modify any file in the container filesystem and increases the impact of container escape vulnerabilities. | +| Recommendation | Never run as root. Keep the `sandbox` user. | + +### PATH Hardening + +The entrypoint locks the `PATH` environment variable to system directories, preventing the agent from injecting malicious binaries into command resolution. + +| Aspect | Detail | +|---|---| +| Default | The entrypoint sets `PATH` to `/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin` at startup. | +| What you can change | This is not a user-facing knob. The entrypoint enforces it. | +| Risk if relaxed | Without PATH hardening, the agent could create an executable named `curl` or `git` in a writable directory earlier in the PATH, intercepting commands run by the entrypoint or other processes. | +| Recommendation | No action needed. The entrypoint handles this automatically. | + +### Build Toolchain Removal + +The Dockerfile removes compilers and network probes from the runtime image. + +| Aspect | Detail | +|---|---| +| Default | The Dockerfile purges `gcc`, `gcc-12`, `g++`, `g++-12`, `cpp`, `cpp-12`, `make`, `netcat-openbsd`, `netcat-traditional`, and `ncat` from the sandbox image. | +| What you can change | Modify the Dockerfile to keep these tools, or install them at runtime if package manager access is allowed. | +| Risk if relaxed | A compiler lets the agent build arbitrary native code, including kernel exploits or custom network tools. `netcat` enables arbitrary TCP connections that bypass HTTP-level policy enforcement. | +| Recommendation | Keep build tools removed. If the agent needs to compile code, run the build in a separate, purpose-built container and copy artifacts into the sandbox. | + +## Gateway Authentication Controls + +The OpenClaw gateway authenticates devices that connect to the Control UI dashboard. +NemoClaw hardens these defaults at image build time. + +### Device Authentication + +Device authentication requires each connecting device to go through a pairing flow before it can interact with the gateway. + +| Aspect | Detail | +|---|---| +| Default | Enabled. The gateway requires device pairing for all connections. | +| What you can change | Set `NEMOCLAW_DISABLE_DEVICE_AUTH=1` as a Docker build argument to disable device authentication. This is a build-time setting baked into `openclaw.json` and verified by hash at startup. | +| Risk if relaxed | Disabling device auth allows any device on the network to connect to the gateway without proving identity. This is dangerous when combined with LAN-bind changes or cloudflared tunnels in remote deployments, resulting in an unauthenticated, publicly reachable dashboard. | +| Recommendation | Keep device auth enabled (the default). Only disable it for headless or development environments where no untrusted devices can reach the gateway. | + +### Insecure Auth Derivation + +The `allowInsecureAuth` setting controls whether the gateway permits non-HTTPS authentication. + +| Aspect | Detail | +|---|---| +| Default | Derived from the `CHAT_UI_URL` scheme at build time. When the URL uses `http://` (local development), insecure auth is allowed. When it uses `https://` (remote or production), insecure auth is blocked. | +| What you can change | This is derived automatically from `CHAT_UI_URL`. Set `CHAT_UI_URL` to an `https://` URL to enforce secure auth. | +| Risk if relaxed | Allowing insecure auth over HTTPS defeats the purpose of TLS, because authentication tokens transit in cleartext. | +| Recommendation | Use `https://` for any deployment accessible beyond `localhost`. The default local URL (`http://127.0.0.1:18789`) correctly allows insecure auth for local development. | + +### Auto-Pair Client Allowlist + +The auto-pair watcher automatically approves device pairing requests from recognized clients, so you do not need to manually approve the Control UI. + +| Aspect | Detail | +|---|---| +| Default | The watcher approves devices with `clientId` set to `openclaw-control-ui` or `clientMode` set to `webchat`. All other clients are rejected and logged. | +| What you can change | This is not a user-facing knob. The allowlist is defined in the entrypoint script. | +| Risk if relaxed | Approving all device types without validation lets rogue or unexpected clients pair with the gateway unchallenged. | +| Recommendation | No action needed. The entrypoint handles this automatically. If you see `[auto-pair] rejected unknown client=...` in the logs, investigate the source of the unexpected connection. | + +### CLI Secret Redaction + +The CLI automatically redacts secret patterns (API keys, bearer tokens, provider credentials) from command output and error messages before logging them. + +| Aspect | Detail | +|---|---| +| Default | Enabled. The runner redacts secrets from stdout, stderr, and thrown error messages. | +| What you can change | This is not a user-facing knob. The CLI enforces it on all command output paths. | +| Risk if relaxed | Without redaction, secrets could appear in terminal scrollback, log files, or debug output shared in bug reports. | +| Recommendation | No action needed. If you share `nemoclaw debug` output, verify that no secrets appear in the collected diagnostics. | + +## Inference Controls + +OpenShell routes all inference traffic through the gateway to isolate provider credentials from the sandbox. + +### Routed Inference through `inference.local` + +The OpenShell gateway intercepts all inference requests from the agent and routes them to the configured provider. +The agent never receives the provider API key. + +| Aspect | Detail | +|---|---| +| Default | The agent talks to `inference.local`. The host owns the credential and upstream endpoint. | +| What you can change | You cannot configure this architecture. The system always enforces it. | +| Risk if bypassed | If the agent could reach an inference endpoint directly (by adding it to the network policy), it would need an API key. Since the sandbox does not contain credentials, this acts as defense-in-depth. However, adding an inference provider's host to the network policy without going through OpenShell routing could let the agent use a stolen or hardcoded key. | +| Recommendation | Do not add inference provider hosts (such as `api.openai.com` or `api.anthropic.com`) to the network policy. Use OpenShell inference routing instead. | + +### Provider Trust Tiers + +Different inference providers have different trust and cost profiles. + +| Provider | Trust level | Cost risk | Data handling | +|---|---|---|---| +| NVIDIA Endpoints | High. Hosted on `build.nvidia.com`. | Pay-per-token with an API key. Unattended agents can accumulate cost. | NVIDIA infrastructure processes requests. | +| OpenAI | High. Commercial API. | Pay-per-token. Same cost risk as NVIDIA Endpoints. | Subject to OpenAI data policies. | +| Anthropic | High. Commercial API. | Pay-per-token. Same cost risk as NVIDIA Endpoints. | Subject to Anthropic data policies. | +| Google Gemini | High. Commercial API. | Pay-per-token. Same cost risk as NVIDIA Endpoints. | Subject to Google data policies. | +| Local Ollama | Self-hosted. No data leaves the machine. | No per-token cost. GPU/CPU resource cost. | Data stays local. | +| Custom compatible endpoint | Varies. Depends on the proxy or gateway. | Varies. | Depends on the endpoint operator. | + +**Recommendation:** For sensitive workloads, use local Ollama to keep data on-premise. For general use, NVIDIA Endpoints provide a good balance of capability and trust. Review the data policies of any cloud provider you use. + +### Experimental Providers + +The `NEMOCLAW_EXPERIMENTAL=1` environment variable gates local NVIDIA NIM and local vLLM. + +| Aspect | Detail | +|---|---| +| Default | Disabled. The onboarding wizard does not show these providers. | +| What you can change | Set `NEMOCLAW_EXPERIMENTAL=1` before running `nemoclaw onboard`. | +| Risk if relaxed | NemoClaw has not fully validated these providers. NIM requires a NIM-capable GPU. vLLM must already be running on `localhost:8000`. Misconfiguration can cause failed inference or unexpected behavior. | +| Recommendation | Use experimental providers only for evaluation. Do not rely on them for always-on assistants. | + +## Posture Profiles + +The following profiles describe how to configure NemoClaw for different use cases. +These are not separate policy files. +They provide guidance on which controls to keep tight or relax. + +### Locked-Down (Default) + +Use for always-on assistants with minimal external access. + +- Keep all defaults. Do not add presets. +- Use operator approval for any endpoint the agent requests. +- Use NVIDIA Endpoints or local Ollama for inference. +- Monitor the TUI for unexpected network requests. + +### Development + +Use when the agent needs package registries, Docker Hub, or broader GitHub access during development tasks. + +- Apply the `pypi` and `npm` presets for package installation. +- Apply the `docker` preset if the agent builds or pulls container images. +- Keep binary restrictions on all presets. +- Review the agent's network activity periodically with `openshell term`. +- Use operator approval for any endpoint not covered by a preset. + +### Integration Testing + +Use when the agent talks to internal APIs or third-party services during testing. + +- Add custom endpoint entries with tight path and method restrictions. +- Use `protocol: rest` for all HTTP APIs to maintain inspection. +- Use operator approval for unknown endpoints during test runs. +- Review and clean up the baseline policy after testing. Remove endpoints that are no longer needed. + +## Common Mistakes + +The following patterns weaken security without providing meaningful benefit. + +| Mistake | Why it matters | What to do instead | +|---------|---------------|-------------------| +| Omitting `protocol: rest` on REST API endpoints | Endpoints without a `protocol` field use L4-only enforcement. The proxy allows the TCP stream through after checking host, port, and binary, but cannot see or filter individual HTTP requests. | Add `protocol: rest` with explicit `rules` to enable per-request method and path control on REST APIs. | +| Adding endpoints to the baseline policy for one-off requests | Adding an endpoint to the baseline policy makes it permanently reachable across all sandbox instances. | Use operator approval. Approved endpoints persist within the sandbox instance but reset when you destroy and recreate the sandbox. | +| Relying solely on the entrypoint for capability drops | The entrypoint drops dangerous capabilities using `capsh`, but this is best-effort. If `capsh` is unavailable or `CAP_SETPCAP` is not in the bounding set, the container runs with the default capability set. | Pass `--cap-drop=ALL` at the container runtime level as defense-in-depth. | +| Granting write access to `/sandbox/.openclaw` | This directory contains the OpenClaw gateway configuration. A writable `.openclaw` lets the agent modify auth tokens, disable CORS, or redirect inference routing. | Store agent-writable state in `/sandbox/.openclaw-data`. | +| Adding inference provider hosts to the network policy | Direct network access to an inference host bypasses credential isolation and usage tracking. | Use OpenShell inference routing instead of adding hosts like `api.openai.com` or `api.anthropic.com` to the network policy. | +| Disabling device auth for remote deployments | Without device auth, any device on the network can connect to the gateway without pairing. Combined with a cloudflared tunnel, this makes the dashboard publicly accessible and unauthenticated. | Keep `NEMOCLAW_DISABLE_DEVICE_AUTH` at its default (`0`). Only set it to `1` for local headless or development environments. | + +## Related Topics + +- Network Policies (see the `nemoclaw-reference` skill) for the full baseline policy reference. +- Customize the Network Policy (see the `nemoclaw-manage-policy` skill) for static and dynamic policy changes. +- Approve or Deny Network Requests (see the `nemoclaw-manage-policy` skill) for the operator approval flow. +- Sandbox Hardening (see the `nemoclaw-deploy-remote` skill) for container-level security measures. +- Inference Options (see the `nemoclaw-configure-inference` skill) for provider configuration details. +- How It Works (see the `nemoclaw-overview` skill) for the protection layer architecture. + diff --git a/.agents/skills/nemoclaw-workspace/SKILL.md b/.agents/skills/nemoclaw-workspace/SKILL.md index de9f11a41..1a03d0001 100644 --- a/.agents/skills/nemoclaw-workspace/SKILL.md +++ b/.agents/skills/nemoclaw-workspace/SKILL.md @@ -1,11 +1,11 @@ --- name: nemoclaw-workspace -description: Hows to back up and restore OpenClaw workspace files before destructive operations. Also covers whats workspace files are, where they live, and how they persist across sandbox restarts. Use when agents.md, back restore workspace files, backup, identity.md, memory.md, nemoclaw, nemoclaw backup, nemoclaw restore. +description: Backs up and restores OpenClaw workspace files before destructive operations. Use when backing up a sandbox, restoring workspace state, or preparing for a destructive operation. Explains what workspace files are, where they live, and how they persist across sandbox restarts. Use when asking about soul.md, identity.md, memory.md, agents.md, or sandbox file persistence. --- -# Nemoclaw Workspace +# NemoClaw Workspace -How to back up and restore OpenClaw workspace files before destructive operations. +Backs up and restores OpenClaw workspace files before destructive operations. Use when backing up a sandbox, restoring workspace state, or preparing for a destructive operation. ## Context diff --git a/.agents/skills/security-code-review/SKILL.md b/.agents/skills/security-code-review/SKILL.md new file mode 100644 index 000000000..db79ab640 --- /dev/null +++ b/.agents/skills/security-code-review/SKILL.md @@ -0,0 +1,175 @@ +--- +name: security-code-review +description: Performs a comprehensive security review of code changes in a GitHub PR or issue. Checks out the branch, analyzes changed files against a 9-category security checklist, and produces PASS/WARNING/FAIL verdicts. Use when reviewing pull requests for security vulnerabilities, hardcoded secrets, injection flaws, auth bypasses, or insecure configurations. Trigger keywords - security review, code review, appsec, vulnerability assessment, security audit, review PR security. +user_invocable: true +--- + +# Security Code Review + +Perform a thorough security review of the changes in a GitHub PR or issue, producing a structured report with per-category verdicts. + +## Prerequisites + +- `gh` (GitHub CLI) must be installed and authenticated. +- `git` must be available. +- Network access to clone repositories and fetch PR metadata. + +## When to Use + +- Reviewing a pull request before merge for security vulnerabilities. +- Triaging a GitHub issue that reports a potential security flaw. +- Auditing code changes for hardcoded secrets, injection flaws, auth bypasses, or insecure configurations. + +## Step 1: Parse the GitHub URL + +If the user provided a PR or issue URL, extract the owner, repo, and number. If not, ask for one. + +Supported URL formats: + +- `https://github.com/OWNER/REPO/pull/NUMBER` +- `https://github.com/OWNER/REPO/issues/NUMBER` + +## Step 2: Check Out the Code + +Determine whether you are already in the target repository (compare `gh repo view --json nameWithOwner -q .nameWithOwner` against the URL). If you are: + +```bash +gh pr checkout +``` + +If reviewing a different repo, clone it to a temporary directory first: + +```bash +TMPDIR=$(mktemp -d) +gh repo clone OWNER/REPO "$TMPDIR" +cd "$TMPDIR" +gh pr checkout +``` + +## Step 3: Identify Changed Files + +List all files changed relative to the base branch: + +```bash +git diff main...HEAD --name-status +``` + +If the PR targets a branch other than `main`, use the correct base. Check with: + +```bash +gh pr view --json baseRefName -q .baseRefName +``` + +## Step 4: Read Every Changed File and Diff + +Read the full content of each changed file and the diff for that file: + +```bash +git diff main...HEAD -- +``` + +For large PRs (more than 30 changed files), prioritize files in this order: + +1. Files that handle authentication, authorization, or credentials. +2. Files that process user input (API handlers, CLI argument parsing, URL parsing). +3. Configuration files (Dockerfiles, YAML policies, environment configs). +4. New dependencies (package.json, requirements.txt, go.mod changes). +5. Everything else. + +## Step 5: Analyze Against the Security Checklist + +For each of the 9 categories below, assign a verdict: + +- **PASS** — no issues found (brief justification). +- **WARNING** — potential concern (describe risk and suggested fix). +- **FAIL** — confirmed vulnerability (describe impact, severity, and remediation). + +### Category 1: Secrets and Credentials + +- No hardcoded secrets, API keys, passwords, tokens, or connection strings in code, configs, or test fixtures. +- No secrets committed to version control (check for `.env` files, PEM/key files, credential JSON). +- Tokens and credentials passed via environment variables or secret stores, not string literals. + +### Category 2: Input Validation and Data Sanitization + +- All user-controlled inputs (APIs, forms, URLs, headers, query params, file uploads) are validated against an allowlist of expected types, lengths, and formats. +- Proper encoding and escaping to prevent XSS, SQL injection, command injection, path traversal, and SSRF. +- Deserialization of untrusted data uses safe parsers (no `pickle.loads`, `yaml.unsafe_load`, `eval`, `new Function`, or similar). + +### Category 3: Authentication and Authorization + +- All new or modified endpoints enforce authentication before processing requests. +- Authorization logic ensures users can only access or modify resources they own or are permitted to use. +- No privilege escalation paths (horizontal or vertical). +- Token validation (expiry, signature, scope) is correctly implemented. + +### Category 4: Dependencies and Third-Party Libraries + +- Newly added dependencies checked for known CVEs (OSV, Snyk, GitHub Advisory DB). +- Dependencies pinned to specific, secure versions (no floating ranges in production). +- OSS license compatibility not violated. +- Dependencies pulled from trusted registries only. + +### Category 5: Error Handling and Logging + +- Error responses do not leak stack traces, internal paths, or sensitive data. +- Logging does not record secrets, tokens, passwords, or PII. +- Exceptions caught at appropriate boundaries; no unhandled crashes that expose state. + +### Category 6: Cryptography and Data Protection + +- Standard, up-to-date algorithms (AES-256-GCM, RSA-2048+, SHA-256+). +- No MD5 or SHA-1 for security purposes. No custom cryptography. +- Sensitive data encrypted at rest and in transit where applicable. + +### Category 7: Configuration and Security Headers + +- Secure defaults (debug mode off, restrictive permissions, minimal port exposure). +- If HTTP endpoints are present: CSP and CORS configured correctly. No wildcard origins in authenticated contexts. +- Container images use non-root users, minimal base images, and pinned digests. + +### Category 8: Security Testing + +- Tests cover security edge cases: malicious input, boundary values, unauthorized access attempts. +- Existing security test coverage not degraded by the change. +- Negative test cases verify that forbidden actions are denied. + +### Category 9: Holistic Security Posture + +- Changes do not degrade overall security posture. +- No false sense of security (client-only validation, incomplete checks). +- Least privilege followed for code, services, and users. +- No TOCTOU race conditions in security-critical paths. +- No unsafe concurrency that bypasses security checks. + +## Step 6: Produce the Report + +Structure the output as follows: + +### Verdict + +One paragraph summarizing the overall risk assessment and whether the PR is safe to merge. + +### Findings Table + +One row per finding: + +| # | Category | Severity | File:Line | Description | Recommendation | +|---|----------|----------|-----------|-------------|----------------| + +If no findings, state explicitly that the review is clean. + +### Detailed Analysis + +Per-category breakdown (categories 1 through 9), each with its PASS, WARNING, or FAIL verdict and justification. + +### Files Reviewed + +List every file analyzed. + +## Important Notes + +- If the PR has no changed files or is a draft with no code, state that and skip the analysis. +- For NemoClaw PRs, pay special attention to sandbox escape vectors: SSRF bypasses, Dockerfile injection, network policy circumvention, credential leakage, and blueprint tampering. +- Do not skip categories. If a category is not applicable to the changes (e.g., no cryptography involved), mark it PASS with "Not applicable — no cryptographic operations in this change." +- When in doubt about severity, err on the side of WARNING rather than PASS. diff --git a/.dockerignore b/.dockerignore index 8d42c6b17..1d369f539 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,3 +4,9 @@ node_modules *.pyc __pycache__ .pytest_cache +.venv +.ruff_cache +.mypy_cache +.env +*.egg-info +.DS_Store diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..03ada1530 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,38 @@ +# .github/CODEOWNERS +# +# Auto-assigns reviewers to PRs. Each line maps a path pattern to one or more +# GitHub teams. Last matching pattern wins. GitHub round-robins review requests +# within each team to spread the load. +# +# Docs: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners + +# ── Fallback: maintainers review anything not matched below ── +* @NVIDIA/nemoclaw-maintainer + +# ── CLI plugin (Node/TS) ── +/nemoclaw/ @NVIDIA/nemoclaw-maintainer +/nemoclaw/src/onboard/ @NVIDIA/nemoclaw-engineer + +# ── Blueprint & sandbox policy (Python) ── +/nemoclaw-blueprint/ @NVIDIA/nemoclaw-maintainer +/nemoclaw-blueprint/policies/ @NVIDIA/nemoclaw-security + +# ── Shell scripts & installers ── +/bin/ @NVIDIA/nemoclaw-maintainer +/scripts/ @NVIDIA/nemoclaw-maintainer +/install.sh @NVIDIA/nemoclaw-maintainer +/uninstall.sh @NVIDIA/nemoclaw-maintainer + +# ── Container ── +/Dockerfile @NVIDIA/nemoclaw-security @NVIDIA/nemoclaw-maintainer + +# ── Docs ── +/docs/ @NVIDIA/nemoclaw-engineer +/spark-install.md @NVIDIA/nemoclaw-engineer + +# ── Tests ── +/test/ @NVIDIA/nemoclaw-engineer + +# ── CI / GitHub config ── +/.github/ @NVIDIA/nemoclaw-maintainer +/ci/ @NVIDIA/nemoclaw-maintainer diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index bdf535eb9..a9107f39e 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -40,3 +40,7 @@ - [ ] Follows the [style guide](https://github.com/NVIDIA/NemoClaw/blob/main/docs/CONTRIBUTING.md). Try running the `update-docs` agent skill to draft changes while complying with the style guide. For example, prompt your agent with "`/update-docs` catch up the docs for the new changes I made in this PR." - [ ] New pages include SPDX license header and frontmatter, if creating a new page. - [ ] Cross-references and links verified. + +--- + +Signed-off-by: Your Name diff --git a/.github/actions/basic-checks/action.yaml b/.github/actions/basic-checks/action.yaml new file mode 100644 index 000000000..efe15f965 --- /dev/null +++ b/.github/actions/basic-checks/action.yaml @@ -0,0 +1,43 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: basic-checks +description: Run the shared Node.js, hadolint, build, and prek-based checks used by CI. + +runs: + using: composite + steps: + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "22" + cache: npm + + - name: Install hadolint + shell: bash + run: | + HADOLINT_VERSION="v2.14.0" + HADOLINT_URL="https://github.com/hadolint/hadolint/releases/download/${HADOLINT_VERSION}/hadolint-Linux-x86_64" + curl -fsSL -o /usr/local/bin/hadolint "$HADOLINT_URL" + EXPECTED=$(curl -fsSL "${HADOLINT_URL}.sha256" | awk '{print $1}') + ACTUAL=$(sha256sum /usr/local/bin/hadolint | awk '{print $1}') + [ "$EXPECTED" = "$ACTUAL" ] || { echo "::error::hadolint checksum mismatch"; exit 1; } + chmod +x /usr/local/bin/hadolint + + - name: Install dependencies + shell: bash + run: | + npm install --ignore-scripts + cd nemoclaw && npm install + + - name: Build TypeScript plugin + shell: bash + run: cd nemoclaw && npm run build + + - name: Build CLI TypeScript modules + shell: bash + run: npm run build:cli + + - name: Run checks + shell: bash + run: npx prek run --all-files --stage pre-push diff --git a/.github/actions/resolve-sandbox-base-image/action.yaml b/.github/actions/resolve-sandbox-base-image/action.yaml new file mode 100644 index 000000000..dc44380ad --- /dev/null +++ b/.github/actions/resolve-sandbox-base-image/action.yaml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: resolve-sandbox-base-image +description: Resolve the sandbox base image from GHCR, falling back to a local Dockerfile.base build. + +runs: + using: composite + steps: + - name: Resolve sandbox base image + shell: bash + run: | + if docker pull ghcr.io/nvidia/nemoclaw/sandbox-base:latest 2>/dev/null; then + echo "BASE_IMAGE=ghcr.io/nvidia/nemoclaw/sandbox-base:latest" >> "$GITHUB_ENV" + else + echo "::warning::GHCR base image not available, building locally" + docker build -f Dockerfile.base -t nemoclaw-sandbox-base-local . + echo "BASE_IMAGE=nemoclaw-sandbox-base-local" >> "$GITHUB_ENV" + fi diff --git a/.github/dco-bypass.txt b/.github/dco-bypass.txt new file mode 100644 index 000000000..cb1a5eb81 --- /dev/null +++ b/.github/dco-bypass.txt @@ -0,0 +1,28 @@ +adityanjothi +brandonpelfrey +cjagwani +cr7258 +cv +dnandakumar-nv +ericksoa +gagandaroach +hulynn +jacobtomlinson +jayavenkatesh19 +jbfbell +jieunl24 +jneeee +jyaunches +kjw3 +krmurph +mercl-lau +miyoungc +nv-ddave +nv-kasikritc +paritoshd-nv +prekshivyas +rwipfelnv +sayalinvidia +senthilr-nv +theFong +wscurran diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..0b0877384 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/base-image.yaml b/.github/workflows/base-image.yaml new file mode 100644 index 000000000..3c32b9e5f --- /dev/null +++ b/.github/workflows/base-image.yaml @@ -0,0 +1,76 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Build and push the sandbox base image to GHCR. +# +# Triggers: +# - Push to main when Dockerfile.base changes (new apt pkgs, openclaw bump, etc.) +# - Manual dispatch for ad-hoc rebuilds +# +# The base image contains the expensive, rarely-changing layers (apt, gosu, +# user setup, openclaw CLI). The production Dockerfile layers PR-specific +# code on top via: FROM ghcr.io/nvidia/nemoclaw/sandbox-base: + +name: base-image + +on: + push: + branches: [main] + paths: + - "Dockerfile.base" + workflow_dispatch: + +permissions: + contents: read + packages: write + +concurrency: + group: base-image + cancel-in-progress: true + +env: + REGISTRY: ghcr.io + IMAGE_NAME: nvidia/nemoclaw/sandbox-base + +jobs: + build-and-push: + if: github.repository == 'NVIDIA/NemoClaw' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up QEMU (arm64 emulation) + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest + type=sha,prefix=,format=short + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile.base + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/commit-lint.yaml b/.github/workflows/commit-lint.yaml index dec431f75..b9cc943b6 100644 --- a/.github/workflows/commit-lint.yaml +++ b/.github/workflows/commit-lint.yaml @@ -21,8 +21,6 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 - with: - fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v6 @@ -33,14 +31,7 @@ jobs: - name: Install dependencies run: npm install --ignore-scripts - - name: Lint commits - if: github.event.action != 'edited' - env: - FROM_SHA: ${{ github.event.pull_request.base.sha }} - TO_SHA: ${{ github.event.pull_request.head.sha }} - run: npx commitlint --from "$FROM_SHA" --to "$TO_SHA" --verbose - - - name: Lint PR title (squash-merge path) + - name: Lint PR title env: PR_TITLE: ${{ github.event.pull_request.title }} run: printf '%s\n' "$PR_TITLE" | npx commitlint --verbose diff --git a/.github/workflows/dco-check.yaml b/.github/workflows/dco-check.yaml new file mode 100644 index 000000000..e8ba5ea50 --- /dev/null +++ b/.github/workflows/dco-check.yaml @@ -0,0 +1,53 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: dco-check + +on: + pull_request: + types: [opened, edited, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + dco-check: + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Check DCO bypass list + id: dco-bypass + env: + USERNAME: ${{ github.event.pull_request.user.login }} + run: | + if grep -Fxq "$USERNAME" .github/dco-bypass.txt; then + echo "bypass=true" >> "$GITHUB_OUTPUT" + echo "Author is in dco-bypass.txt; skipping DCO sign-off requirement." + else + echo "bypass=false" >> "$GITHUB_OUTPUT" + echo "Author is not in dco-bypass.txt; DCO sign-off is required." + fi + + - name: Check PR body for Signed-off-by + if: ${{ steps.dco-bypass.outputs.bypass != 'true' }} + env: + PR_BODY: ${{ github.event.pull_request.body }} + run: | + normalized_body="$(printf '%s\n' "$PR_BODY" | tr -d '\r')" + + if ! printf '%s\n' "$normalized_body" | grep -qP '^Signed-off-by:\s+.+\s+<[^<>]+>$'; then + echo "::error::PR description must contain a DCO sign-off line." + echo "::error::Expected format: Signed-off-by: Your Name " + exit 1 + fi + + echo "PR description contains a DCO sign-off." diff --git a/.github/workflows/docker-pin-check.yaml b/.github/workflows/docker-pin-check.yaml index 740a95ee0..f359d4fe3 100644 --- a/.github/workflows/docker-pin-check.yaml +++ b/.github/workflows/docker-pin-check.yaml @@ -25,4 +25,6 @@ jobs: uses: actions/checkout@v6 - name: Check Dockerfile base-image pin - run: bash scripts/update-docker-pin.sh --check + run: | + bash scripts/update-docker-pin.sh --check + DOCKERFILE=Dockerfile.base bash scripts/update-docker-pin.sh --check diff --git a/.github/workflows/docs-preview-deploy.yaml b/.github/workflows/docs-preview-deploy.yaml index 0bc2ab389..8ea0df3f9 100644 --- a/.github/workflows/docs-preview-deploy.yaml +++ b/.github/workflows/docs-preview-deploy.yaml @@ -67,7 +67,7 @@ jobs: - name: Deploy preview if: steps.meta.outputs.action != 'closed' && steps.meta.outputs.same-repo == 'true' - uses: rossjrw/pr-preview-action@v1 + uses: rossjrw/pr-preview-action@ffa7509e91a3ec8dfc2e5536c4d5c1acdf7a6de9 # v1 with: source-dir: ./docs-preview-html/ preview-branch: gh-pages @@ -78,7 +78,7 @@ jobs: - name: Post preview comment if: steps.meta.outputs.action != 'closed' && steps.meta.outputs.same-repo == 'true' - uses: marocchino/sticky-pull-request-comment@v2 + uses: marocchino/sticky-pull-request-comment@70d2764d1a7d5d9560b100cbea0077fc8f633987 # v3.0.2 with: header: pr-preview number: ${{ steps.meta.outputs.pr-number }} @@ -89,7 +89,7 @@ jobs: - name: Remove preview if: steps.meta.outputs.action == 'closed' - uses: rossjrw/pr-preview-action@v1 + uses: rossjrw/pr-preview-action@ffa7509e91a3ec8dfc2e5536c4d5c1acdf7a6de9 # v1 with: preview-branch: gh-pages umbrella-dir: pr-preview @@ -99,7 +99,7 @@ jobs: - name: Remove preview comment if: steps.meta.outputs.action == 'closed' - uses: marocchino/sticky-pull-request-comment@v2 + uses: marocchino/sticky-pull-request-comment@70d2764d1a7d5d9560b100cbea0077fc8f633987 # v3.0.2 with: header: pr-preview number: ${{ steps.meta.outputs.pr-number }} diff --git a/.github/workflows/e2e-brev.yaml b/.github/workflows/e2e-brev.yaml index c8849e1ac..5f8e36502 100644 --- a/.github/workflows/e2e-brev.yaml +++ b/.github/workflows/e2e-brev.yaml @@ -3,31 +3,59 @@ name: e2e-brev +# Ephemeral Brev E2E: provisions a cloud instance, bootstraps NemoClaw, +# runs test suites remotely, then tears down. Use workflow_dispatch to +# trigger manually from the Actions tab, or workflow_call from other workflows. +# +# Test suites: +# full — Install → onboard → sandbox verify → live inference +# against NVIDIA Endpoints → CLI operations. Tests the +# complete user journey. (~10 min, destroys sandbox) +# credential-sanitization — 24 tests validating PR #743: credential stripping from +# migration snapshots, auth-profiles.json deletion, blueprint +# digest verification, symlink traversal protection, and +# runtime sandbox credential checks. Requires running sandbox. +# telegram-injection — 18 tests validating PR #584: command injection prevention +# through $(cmd), backticks, quote breakout, ${VAR} expansion, +# process table leak checks, and SANDBOX_NAME validation. +# Requires running sandbox. +# all — Runs credential-sanitization + telegram-injection (NOT full, +# which destroys the sandbox the security tests need). +# +# Required secrets: BREV_API_TOKEN, NVIDIA_API_KEY +# Instance cost: Brev CPU credits (~$0.10/run for 4x16 instance) + on: workflow_dispatch: inputs: - branch: - description: "Branch to test" - required: true - default: "main" pr_number: - description: "PR number (for status reporting, optional)" + description: "PR number (resolves branch automatically)" required: false default: "" test_suite: - description: "Test suite to run" + description: "Test suite to run (see workflow header for descriptions)" required: true default: "full" type: choice options: - full - credential-sanitization + - telegram-injection - all + use_launchable: + description: "Use CI launchable (true) or bare brev create + brev-setup.sh (false)" + required: false + type: boolean + default: true keep_alive: description: "Keep Brev instance alive after tests (for SSH debugging)" required: false type: boolean - default: true + default: false + brev_token: + description: "Brev refresh token (overrides BREV_API_TOKEN secret if provided)" + required: false + default: "" workflow_call: inputs: branch: @@ -41,6 +69,14 @@ on: required: false type: string default: "full" + use_launchable: + required: false + type: boolean + default: true + setup_script_url: + required: false + type: string + default: "" keep_alive: required: false type: boolean @@ -62,14 +98,23 @@ concurrency: jobs: e2e-brev: - if: github.repository == 'NVIDIA/NemoClaw' + # if: github.repository == 'NVIDIA/NemoClaw' # Disabled for fork testing — re-enable before merge runs-on: ubuntu-latest - timeout-minutes: 45 + timeout-minutes: 90 steps: + - name: Resolve branch from PR number + if: inputs.pr_number != '' + env: + GH_TOKEN: ${{ github.token }} + run: | + BRANCH=$(gh pr view ${{ inputs.pr_number }} --repo ${{ github.repository }} --json headRefName -q .headRefName) + echo "Resolved PR #${{ inputs.pr_number }} → branch: $BRANCH" + echo "RESOLVED_BRANCH=$BRANCH" >> "$GITHUB_ENV" + - name: Checkout target branch uses: actions/checkout@v6 with: - ref: ${{ inputs.branch }} + ref: ${{ env.RESOLVED_BRANCH || inputs.branch || 'main' }} - name: Create check run (pending) if: inputs.pr_number != '' @@ -95,8 +140,9 @@ jobs: - name: Install Brev CLI run: | - # Pin to v0.6.310 — v0.6.322 removed --cpu flag and defaults to GPU instances - curl -fsSL -o /tmp/brev.tar.gz "https://github.com/brevdev/brev-cli/releases/download/v0.6.310/brev-cli_0.6.310_linux_amd64.tar.gz" + # Brev CLI v0.6.322+ — CPU instances use `brev search cpu | brev create` + # Startup scripts use `brev create --startup-script @file` (not brev start --cpu) + curl -fsSL -o /tmp/brev.tar.gz "https://github.com/brevdev/brev-cli/releases/download/v0.6.322/brev-cli_0.6.322_linux_amd64.tar.gz" tar -xzf /tmp/brev.tar.gz -C /usr/local/bin brev chmod +x /usr/local/bin/brev @@ -105,11 +151,14 @@ jobs: - name: Run ephemeral Brev E2E env: - BREV_API_TOKEN: ${{ secrets.BREV_API_TOKEN }} + BREV_API_TOKEN: ${{ inputs.brev_token || secrets.BREV_API_TOKEN }} NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }} GITHUB_TOKEN: ${{ github.token }} INSTANCE_NAME: e2e-pr-${{ inputs.pr_number || github.run_id }} TEST_SUITE: ${{ inputs.test_suite }} + USE_LAUNCHABLE: ${{ inputs.use_launchable && '1' || '0' }} + LAUNCHABLE_SETUP_SCRIPT: ${{ inputs.setup_script_url || '' }} + BREV_PROVIDER: gcp KEEP_ALIVE: ${{ inputs.keep_alive }} run: npx vitest run --project e2e-brev --reporter=verbose @@ -139,7 +188,8 @@ jobs: STATUS="FAILED" fi INSTANCE="e2e-pr-${{ inputs.pr_number || github.run_id }}" - BODY="${EMOJI} **Brev E2E** (${{ inputs.test_suite }}): **${STATUS}** on branch \`${{ inputs.branch }}\` — [See logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})" + BRANCH="${RESOLVED_BRANCH:-${{ inputs.branch || 'main' }}}" + BODY="${EMOJI} **Brev E2E** (${{ inputs.test_suite }}): **${STATUS}** on branch \`${BRANCH}\` — [See logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})" if [ "${{ inputs.keep_alive }}" = "true" ]; then BODY="${BODY} diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml new file mode 100644 index 000000000..47f6e7123 --- /dev/null +++ b/.github/workflows/main.yaml @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: main + +on: + push: + branches: [main] + paths-ignore: + - "docs/**" + - "**/*.md" + - ".github/workflows/docs-preview-*.yaml" + - "ISSUE_TEMPLATE/**" + - ".github/ISSUE_TEMPLATE/**" + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + checks: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Run basic checks + uses: ./.github/actions/basic-checks + + sandbox-images-and-e2e: + needs: checks + uses: ./.github/workflows/sandbox-images-and-e2e.yaml diff --git a/.github/workflows/nightly-e2e.yaml b/.github/workflows/nightly-e2e.yaml index 86494a9e3..c15f11914 100644 --- a/.github/workflows/nightly-e2e.yaml +++ b/.github/workflows/nightly-e2e.yaml @@ -1,11 +1,25 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # -# Nightly full E2E: install → onboard → live inference against NVIDIA Endpoint API. +# Nightly E2E tests: +# +# cloud-e2e Cloud inference (NVIDIA Endpoint API) on ubuntu-latest. +# cloud-experimental-e2e Experimental cloud inference test (main script skips embedded +# check-docs + final cleanup; follow-up steps run check-docs, +# skip/05-network-policy.sh, then cleanup.sh --verify with if: always()). +# gpu-e2e Local Ollama inference on a GPU self-hosted runner. +# Controlled by the GPU_E2E_ENABLED repository variable. +# Set vars.GPU_E2E_ENABLED to "true" in repo settings to enable. +# notify-on-failure Auto-creates a GitHub issue when any E2E job fails. +# # Runs directly on the runner (not inside Docker) because OpenShell bootstraps # a K3s cluster inside a privileged Docker container — nesting would break networking. # -# Requires NVIDIA_API_KEY repository secret. +# NVIDIA_API_KEY for cloud-e2e and cloud-experimental-e2e: +# - Repository secret: Settings → Secrets and variables → Actions → Repository secrets. +# - Environment secret: only available if the job sets `environment: `. +# (Storing the key under Environments / NVIDIA_API_KEY without `environment:` here leaves the +# variable empty in the job — repository secrets and environment secrets are separate.) # Only runs on schedule and manual dispatch — never on PRs (secret protection). name: nightly-e2e @@ -23,7 +37,7 @@ concurrency: cancel-in-progress: true jobs: - full-e2e: + cloud-e2e: if: github.repository == 'NVIDIA/NemoClaw' runs-on: ubuntu-latest timeout-minutes: 45 @@ -31,10 +45,11 @@ jobs: - name: Checkout uses: actions/checkout@v6 - - name: Run full E2E test + - name: Run cloud E2E test env: NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }} NEMOCLAW_NON_INTERACTIVE: "1" + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1" NEMOCLAW_SANDBOX_NAME: "e2e-nightly" NEMOCLAW_RECREATE_SANDBOX: "1" GITHUB_TOKEN: ${{ github.token }} @@ -51,12 +66,14 @@ jobs: cloud-experimental-e2e: if: github.repository == 'NVIDIA/NemoClaw' runs-on: ubuntu-latest - environment: NVIDIA_API_KEY - timeout-minutes: 45 + # Main suite + check-docs + network-policy skip script can exceed 45m on cold runners. + timeout-minutes: 90 steps: - name: Checkout uses: actions/checkout@v6 + # Split Phase 5f (check-docs) and Phase 6 (cleanup) out of the main script so CI shows + # failures in dedicated steps; tear-down always runs last (if: always()). - name: Run cloud-experimental E2E test env: NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }} @@ -64,15 +81,172 @@ jobs: # Non-interactive install (expect-driven Phase 3 optional). Runner has no expect; Phase 5e TUI skips if expect is absent. RUN_E2E_CLOUD_EXPERIMENTAL_INTERACTIVE_INSTALL: "0" NEMOCLAW_NON_INTERACTIVE: "1" + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1" NEMOCLAW_RECREATE_SANDBOX: "1" NEMOCLAW_POLICY_MODE: "custom" NEMOCLAW_POLICY_PRESETS: "npm,pypi" + RUN_E2E_CLOUD_EXPERIMENTAL_SKIP_FINAL_CLEANUP: "1" + RUN_E2E_CLOUD_EXPERIMENTAL_SKIP_CHECK_DOCS: "1" run: bash test/e2e/test-e2e-cloud-experimental.sh + - name: Documentation checks (check-docs.sh) + if: always() + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + if [ -f "$HOME/.bashrc" ]; then + # shellcheck source=/dev/null + source "$HOME/.bashrc" 2>/dev/null || true + fi + export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" + if [ -s "$NVM_DIR/nvm.sh" ]; then + # shellcheck source=/dev/null + . "$NVM_DIR/nvm.sh" + fi + if [ -d "$HOME/.local/bin" ] && [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then + export PATH="$HOME/.local/bin:$PATH" + fi + bash test/e2e/e2e-cloud-experimental/check-docs.sh + + - name: Network policy checks (skip/05-network-policy.sh) + if: always() + env: + NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }} + GITHUB_TOKEN: ${{ github.token }} + SANDBOX_NAME: e2e-cloud-experimental + NEMOCLAW_SANDBOX_NAME: e2e-cloud-experimental + run: | + set -euo pipefail + if [ -f "$HOME/.bashrc" ]; then + # shellcheck source=/dev/null + source "$HOME/.bashrc" 2>/dev/null || true + fi + export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" + if [ -s "$NVM_DIR/nvm.sh" ]; then + # shellcheck source=/dev/null + . "$NVM_DIR/nvm.sh" + fi + if [ -d "$HOME/.local/bin" ] && [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then + export PATH="$HOME/.local/bin:$PATH" + fi + bash test/e2e/e2e-cloud-experimental/skip/05-network-policy.sh + + - name: Tear down cloud-experimental sandbox (always) + if: always() + env: + SANDBOX_NAME: e2e-cloud-experimental + NEMOCLAW_SANDBOX_NAME: e2e-cloud-experimental + run: | + set -euo pipefail + if [ -f "$HOME/.bashrc" ]; then + # shellcheck source=/dev/null + source "$HOME/.bashrc" 2>/dev/null || true + fi + export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" + if [ -s "$NVM_DIR/nvm.sh" ]; then + # shellcheck source=/dev/null + . "$NVM_DIR/nvm.sh" + fi + if [ -d "$HOME/.local/bin" ] && [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then + export PATH="$HOME/.local/bin:$PATH" + fi + bash test/e2e/e2e-cloud-experimental/cleanup.sh --verify + - name: Upload install log on failure if: failure() uses: actions/upload-artifact@v4 with: name: install-log-cloud-experimental - path: /tmp/nemoclaw-e2e-install.log + path: /tmp/nemoclaw-e2e-cloud-experimental-install.log + if-no-files-found: ignore + + # ── GPU E2E (Ollama local inference) ────────────────────────── + # Enable by setting repository variable GPU_E2E_ENABLED=true + # (Settings → Secrets and variables → Actions → Variables) + # + # Runner labels: using 'self-hosted' for now. Refine to + # [self-hosted, linux, x64, gpu] once NVIDIA runner labels are confirmed. + gpu-e2e: + if: github.repository == 'NVIDIA/NemoClaw' && vars.GPU_E2E_ENABLED == 'true' + runs-on: self-hosted + timeout-minutes: 60 + env: + NEMOCLAW_NON_INTERACTIVE: "1" + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1" + NEMOCLAW_SANDBOX_NAME: "e2e-gpu-ollama" + NEMOCLAW_RECREATE_SANDBOX: "1" + NEMOCLAW_PROVIDER: "ollama" + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Verify GPU availability + run: | + echo "=== GPU Info ===" + nvidia-smi + echo "" + echo "=== VRAM ===" + nvidia-smi --query-gpu=name,memory.total --format=csv,noheader + echo "" + echo "=== Docker ===" + docker info --format '{{.ServerVersion}}' + + - name: Run GPU E2E test (Ollama local inference) + run: bash test/e2e/test-gpu-e2e.sh + + - name: Upload install log on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: gpu-e2e-install-log + path: /tmp/nemoclaw-gpu-e2e-install.log + if-no-files-found: ignore + + - name: Upload test log on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: gpu-e2e-test-log + path: /tmp/nemoclaw-gpu-e2e-test.log if-no-files-found: ignore + + notify-on-failure: + runs-on: ubuntu-latest + needs: [cloud-e2e, cloud-experimental-e2e, gpu-e2e] + if: ${{ always() && (needs.cloud-e2e.result == 'failure' || needs.cloud-experimental-e2e.result == 'failure' || needs.gpu-e2e.result == 'failure') }} + permissions: + issues: write + steps: + - name: Create or update failure issue + uses: actions/github-script@v7 + with: + script: | + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const title = 'Nightly E2E failed'; + + const { data: existing } = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: 'CI/CD', + per_page: 100, + }); + const match = existing.find(i => !i.pull_request && i.title.startsWith(title)); + + if (match) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: match.number, + body: `Failed again on ${new Date().toISOString().split('T')[0]}.\n\n**Run:** ${runUrl}\n**Artifacts:** Check the run artifacts for install/test logs (artifact names vary by job).`, + }); + } else { + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `${title} — ${new Date().toISOString().split('T')[0]}`, + body: `The nightly E2E pipeline failed.\n\n**Run:** ${runUrl}\n**Artifacts:** Check the run artifacts for install/test logs (artifact names vary by job).`, + labels: ['bug', 'CI/CD'], + }); + } diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index b796065db..1990bb668 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -15,147 +15,18 @@ concurrency: cancel-in-progress: true jobs: - lint: + checks: runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v6 - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: "22" - cache: npm + - name: Run basic checks + uses: ./.github/actions/basic-checks - - name: Install hadolint - run: | - HADOLINT_URL="https://github.com/hadolint/hadolint/releases/latest/download/hadolint-Linux-x86_64" - curl -fsSL -o /usr/local/bin/hadolint "$HADOLINT_URL" - EXPECTED=$(curl -fsSL "${HADOLINT_URL}.sha256" | awk '{print $1}') - ACTUAL=$(sha256sum /usr/local/bin/hadolint | awk '{print $1}') - [ "$EXPECTED" = "$ACTUAL" ] || { echo "::error::hadolint checksum mismatch"; exit 1; } - chmod +x /usr/local/bin/hadolint - - - name: Install dependencies - run: | - npm install --ignore-scripts - cd nemoclaw && npm install - - - name: Build TypeScript plugin - run: cd nemoclaw && npm run build - - - name: Run all hooks (pre-commit + pre-push) - run: npx prek run --all-files --stage pre-push - - test-unit: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: "22" - cache: npm - - - name: Install dependencies - run: | - npm install --ignore-scripts - cd nemoclaw && npm install - - - name: Build TypeScript plugin - run: cd nemoclaw && npm run build - - - name: Run all unit tests with coverage - run: npx vitest run --coverage - - - name: Check coverage ratchet - run: bash scripts/check-coverage-ratchet.sh - - build-sandbox-images: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Build production image - run: docker build -t nemoclaw-production . - - - name: Build sandbox test image (fixtures layered on production) - run: docker build -f test/Dockerfile.sandbox --build-arg BASE_IMAGE=nemoclaw-production -t nemoclaw-sandbox-test . - - - name: Save images to tarballs - run: | - docker save nemoclaw-sandbox-test | gzip > /tmp/sandbox-test-image.tar.gz - docker save nemoclaw-production | gzip > /tmp/isolation-image.tar.gz - - - name: Upload sandbox test image - uses: actions/upload-artifact@v4 - with: - name: sandbox-test-image - path: /tmp/sandbox-test-image.tar.gz - retention-days: 1 - - - name: Upload isolation image - uses: actions/upload-artifact@v4 - with: - name: isolation-image - path: /tmp/isolation-image.tar.gz - retention-days: 1 - - build-sandbox-images-arm64: - runs-on: ubuntu-24.04-arm - timeout-minutes: 15 - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Build production image on arm64 - run: docker build -t nemoclaw-production-arm64 . - - - name: Build sandbox test image on arm64 - run: docker build -f test/Dockerfile.sandbox --build-arg BASE_IMAGE=nemoclaw-production-arm64 -t nemoclaw-sandbox-test-arm64 . - - test-e2e-sandbox: - runs-on: ubuntu-latest - timeout-minutes: 15 - needs: build-sandbox-images - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Download image artifact - uses: actions/download-artifact@v4 - with: - name: sandbox-test-image - path: /tmp - - - name: Load image - run: gunzip -c /tmp/sandbox-test-image.tar.gz | docker load - - - name: Run sandbox E2E tests - run: docker run --rm -v "${{ github.workspace }}/test:/opt/test" nemoclaw-sandbox-test /opt/test/e2e-test.sh - - test-e2e-gateway-isolation: - runs-on: ubuntu-latest - timeout-minutes: 15 - needs: build-sandbox-images - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Download image artifact - uses: actions/download-artifact@v4 - with: - name: isolation-image - path: /tmp - - - name: Load image - run: gunzip -c /tmp/isolation-image.tar.gz | docker load - - - name: Run gateway isolation E2E tests - run: NEMOCLAW_TEST_IMAGE=nemoclaw-production bash test/e2e-gateway-isolation.sh + sandbox-images-and-e2e: + needs: checks + uses: ./.github/workflows/sandbox-images-and-e2e.yaml + with: + run_arm64: true diff --git a/.github/workflows/sandbox-images-and-e2e.yaml b/.github/workflows/sandbox-images-and-e2e.yaml new file mode 100644 index 000000000..dea077d5b --- /dev/null +++ b/.github/workflows/sandbox-images-and-e2e.yaml @@ -0,0 +1,109 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: sandbox-images-and-e2e + +on: + workflow_call: + inputs: + run_arm64: + description: "Build sandbox images on arm64 too" + required: false + type: boolean + default: false + +permissions: + contents: read + +jobs: + build-sandbox-images: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Resolve sandbox base image + uses: ./.github/actions/resolve-sandbox-base-image + + - name: Build production image + run: docker build --build-arg BASE_IMAGE=${{ env.BASE_IMAGE }} -t nemoclaw-production . + + - name: Build sandbox test image (fixtures layered on production) + run: docker build -f test/Dockerfile.sandbox --build-arg BASE_IMAGE=nemoclaw-production -t nemoclaw-sandbox-test . + + - name: Save images to tarballs + run: | + docker save nemoclaw-sandbox-test | gzip > /tmp/sandbox-test-image.tar.gz + docker save nemoclaw-production | gzip > /tmp/isolation-image.tar.gz + + - name: Upload sandbox test image + uses: actions/upload-artifact@v4 + with: + name: sandbox-test-image + path: /tmp/sandbox-test-image.tar.gz + retention-days: 1 + + - name: Upload isolation image + uses: actions/upload-artifact@v4 + with: + name: isolation-image + path: /tmp/isolation-image.tar.gz + retention-days: 1 + + build-sandbox-images-arm64: + if: inputs.run_arm64 + runs-on: ubuntu-24.04-arm + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Resolve sandbox base image + uses: ./.github/actions/resolve-sandbox-base-image + + - name: Build production image on arm64 + run: docker build --build-arg BASE_IMAGE=${{ env.BASE_IMAGE }} -t nemoclaw-production-arm64 . + + - name: Build sandbox test image on arm64 + run: docker build -f test/Dockerfile.sandbox --build-arg BASE_IMAGE=nemoclaw-production-arm64 -t nemoclaw-sandbox-test-arm64 . + + test-e2e-sandbox: + runs-on: ubuntu-latest + timeout-minutes: 15 + needs: build-sandbox-images + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Download image artifact + uses: actions/download-artifact@v4 + with: + name: sandbox-test-image + path: /tmp + + - name: Load image + run: gunzip -c /tmp/sandbox-test-image.tar.gz | docker load + + - name: Run sandbox E2E tests + run: docker run --rm -v "${{ github.workspace }}/test:/opt/test" nemoclaw-sandbox-test /opt/test/e2e-test.sh + + test-e2e-gateway-isolation: + runs-on: ubuntu-latest + timeout-minutes: 15 + needs: build-sandbox-images + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Download image artifact + uses: actions/download-artifact@v4 + with: + name: isolation-image + path: /tmp + + - name: Load image + run: gunzip -c /tmp/isolation-image.tar.gz | docker load + + - name: Run gateway isolation E2E tests + run: NEMOCLAW_TEST_IMAGE=nemoclaw-production bash test/e2e-gateway-isolation.sh diff --git a/.gitignore b/.gitignore index 90e3e0b33..d1991c87c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ # Build artifacts and caches +.version *.pyc *.tsbuildinfo .pytest_cache/ @@ -15,6 +16,7 @@ Thumbs.db # Project-specific draft_newsletter_* +research/ specs/ vdr-notes/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 90430e074..850ebd272 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,7 +19,7 @@ # 5 — Shell / TS formatters (shfmt, prettier) # 6 — Fixes that should follow formatters (ruff check --fix, eslint --fix) # 10 — Linters and read-only checks -# 20 — Project-level checks (vitest) +# 20 — Project-level checks (vitest, coverage, ratchet) exclude: ^(nemoclaw/dist/|nemoclaw/node_modules/|docs/_build/|\.venv/|uv\.lock$) @@ -63,7 +63,7 @@ repos: name: SPDX license headers (insert if missing) entry: bash scripts/check-spdx-headers.sh --fix language: system - files: ^(nemoclaw/src/.*\.ts|nemoclaw-blueprint/.*\.py|.*\.sh)$ + files: ^(nemoclaw/src/.*\.ts|scripts/.*\.ts|nemoclaw-blueprint/.*\.py|.*\.sh)$ exclude: ^nemoclaw-blueprint/.*__init__\.py$ pass_filenames: true priority: 4 @@ -83,19 +83,27 @@ repos: - repo: local hooks: - - id: nemoclaw-prettier - name: Prettier (nemoclaw TypeScript) + - id: prettier-plugin + name: Prettier (plugin) entry: bash -c 'root="$(git rev-parse --show-toplevel)" && cd "$root/nemoclaw" && files=() && for f in "$@"; do files+=("${f#nemoclaw/}"); done && npx prettier --write "${files[@]}"' -- language: system files: ^nemoclaw/.*\.ts$ pass_filenames: true priority: 5 + - id: prettier-js + name: Prettier (JavaScript) + entry: npx prettier --write + language: system + files: ^(bin|test)/.*\.js$ + pass_filenames: true + priority: 5 + # ── Priority 6: auto-fix after formatting ───────────────────────────────── - repo: local hooks: - - id: nemoclaw-eslint - name: ESLint (nemoclaw TypeScript) + - id: eslint-plugin + name: ESLint (plugin) entry: bash -c 'root="$(git rev-parse --show-toplevel)" && cd "$root/nemoclaw" && files=() && for f in "$@"; do files+=("${f#nemoclaw/}"); done && npx eslint --fix "${files[@]}"' -- language: system files: ^nemoclaw/.*\.ts$ @@ -104,8 +112,8 @@ repos: - repo: local hooks: - - id: eslint-js - name: ESLint (JavaScript) + - id: eslint-cli + name: ESLint (CLI) entry: npx eslint --fix language: system files: ^(bin|test|scripts|docs/_ext)/.*\.js$ @@ -165,17 +173,6 @@ repos: - id: markdownlint-cli2 priority: 10 - # ── Priority 20: project-level checks (pre-commit) ───────────────────────── - - repo: local - hooks: - - id: vitest-plugin - name: Vitest (plugin project) - entry: bash -c 'root="$(git rev-parse --show-toplevel)" && cd "$root" && exec ./node_modules/.bin/vitest run --project plugin' - language: system - pass_filenames: false - files: ^nemoclaw/ - priority: 20 - # ── commit-msg hooks ──────────────────────────────────────────────────────── - repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook rev: v9.24.0 @@ -188,24 +185,62 @@ repos: # ── pre-push hooks ───────────────────────────────────────────────────────── - repo: local hooks: - - id: tsc-check - name: TypeScript type check (tsc --noEmit) + - id: tsc-plugin + name: TypeScript (plugin) entry: bash -c 'cd nemoclaw && npx tsc --noEmit --incremental' language: system pass_filenames: false - always_run: true + files: ^nemoclaw/ stages: [pre-push] priority: 10 - - id: tsc-check-js - name: TypeScript type check (JavaScript) - entry: npx tsc -p jsconfig.json + - id: tsc-js + name: TypeScript (JS config) + entry: bash -c 'npm run build:cli && npx tsc -p jsconfig.json' language: system pass_filenames: false files: ^(bin|test|scripts)/.*\.js$ stages: [pre-push] priority: 10 + - id: tsc-cli + name: TypeScript (CLI) + entry: npx tsc -p tsconfig.cli.json + language: system + pass_filenames: false + files: ^(bin|scripts)/ + types_or: [ts, tsx] + stages: [pre-push] + priority: 10 + + - id: version-tag-sync + name: package.json ↔ git tag version sync + entry: bash scripts/check-version-tag-sync.sh + language: system + always_run: true + pass_filenames: false + stages: [pre-push] + priority: 10 + + # ── Priority 20: project-level checks (coverage + ratchet) ───────────────── + - repo: local + hooks: + - id: test-cli + name: Test (CLI) + entry: bash -c 'npm run build:cli && npx vitest run --project cli --coverage --coverage.reporter=text --coverage.reporter=json-summary --coverage.reportsDirectory=coverage/cli --coverage.include="bin/**/*.js" --coverage.include="dist/lib/**/*.js" --coverage.exclude="test/**/*.js" --coverage.exclude="test/**/*.ts" && npx tsx scripts/check-coverage-ratchet.ts coverage/cli/coverage-summary.json ci/coverage-threshold-cli.json "CLI coverage"' + language: system + pass_filenames: false + files: ^(bin/|src/|test/) + priority: 20 + + - id: test-plugin + name: Test (plugin) + entry: bash -c 'npx vitest run --project plugin --coverage --coverage.reporter=text --coverage.reporter=json-summary --coverage.reportsDirectory=coverage/plugin --coverage.include="nemoclaw/src/**/*.ts" --coverage.exclude="**/*.test.ts" && npx tsx scripts/check-coverage-ratchet.ts coverage/plugin/coverage-summary.json ci/coverage-threshold-plugin.json "Plugin coverage"' + language: system + pass_filenames: false + files: ^nemoclaw/ + priority: 20 + default_language_version: python: python3 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..550f6d209 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +coverage/ +nemoclaw/node_modules/ +nemoclaw/dist/ +nemoclaw-blueprint/ +docs/_build/ +*.md diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..4a1222f56 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2 +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..9668357a5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,95 @@ +# AGENTS.md + +## Project Context + +NVIDIA NemoClaw runs OpenClaw AI assistants inside hardened OpenShell sandboxes with NVIDIA Nemotron inference. This file provides agent-specific guidance for working on this codebase. + +## Quick Reference + +| Task | Command | +|------|---------| +| Install all deps | `npm install && cd nemoclaw && npm install && npm run build && cd .. && cd nemoclaw-blueprint && uv sync && cd ..` | +| Run all tests | `npm test` | +| Run plugin tests | `cd nemoclaw && npm test` | +| Run all linters | `make check` | +| Type-check CLI | `npm run typecheck:cli` | +| Build plugin | `cd nemoclaw && npm run build` | +| Build docs | `make docs` | + +## Key Architecture Decisions + +### Dual-Language Stack + +- **CLI and plugin**: JavaScript (CJS in `bin/`, ESM in `test/`) and TypeScript (`nemoclaw/src/`) +- **Blueprint**: YAML configuration (`nemoclaw-blueprint/`) +- **Docs**: Sphinx/MyST Markdown +- **Tooling scripts**: Bash and Python + +The `bin/` directory uses CommonJS intentionally — it's the CLI entry point that must work without a build step. The `nemoclaw/` plugin uses TypeScript and requires compilation. + +### Testing Strategy + +Tests are organized into three Vitest projects defined in `vitest.config.ts`: + +1. **`cli`** — `test/**/*.test.{js,ts}` — integration tests for CLI behavior +2. **`plugin`** — `nemoclaw/src/**/*.test.ts` — unit tests co-located with source +3. **`e2e-brev`** — `test/e2e/brev-e2e.test.js` — cloud E2E (requires `BREV_API_TOKEN`) + +When writing tests: + +- Root-level tests (`test/`) use ESM imports +- Plugin tests use TypeScript and are co-located with their source files +- Mock external dependencies; don't call real NVIDIA APIs in unit tests +- E2E tests run on ephemeral Brev cloud instances + +### Security Model + +NemoClaw isolates agents inside OpenShell sandboxes with: + +- Network policies (`nemoclaw-blueprint/policies/`) controlling egress +- Credential sanitization to prevent leaks +- SSRF validation (`nemoclaw/src/blueprint/ssrf.ts`) +- Docker capability drops and process limits + +Security-sensitive code paths require extra test coverage. + +## Working with This Repo + +### Before Making Changes + +1. Read `CONTRIBUTING.md` for the full contributor guide +2. Run `make check` to verify your environment is set up correctly +3. Check that `npm test` passes before starting + +### Common Patterns + +**Adding a CLI command:** + +- Entry point: `bin/nemoclaw.js` (routes to `bin/lib/` modules) +- Keep `bin/lib/` modules as CommonJS +- Add tests in `test/` + +**Adding a plugin feature:** + +- Source: `nemoclaw/src/` +- Co-locate tests as `*.test.ts` +- Build with `cd nemoclaw && npm run build` + +**Adding a network policy preset:** + +- Add YAML to `nemoclaw-blueprint/policies/presets/` +- Follow existing preset structure (see `slack.yaml`, `discord.yaml`) + +**Updating docs:** + +- Edit under `docs/` (never `.agents/skills/nemoclaw-*/*.md`) +- Regenerate skills: `python scripts/docs-to-skills.py docs/ .agents/skills/ --prefix nemoclaw` +- Preview: `make docs-live` + +### Gotchas + +- `npm install` at root triggers `prek install` which sets up git hooks. If hooks fail, check that `core.hooksPath` is unset: `git config --unset core.hooksPath` +- The `nemoclaw/` subdirectory has its own `package.json`, `node_modules/`, and ESLint config — it's a separate npm project +- SPDX headers are auto-inserted by pre-commit hooks; don't worry about adding them manually +- Coverage thresholds are ratcheted in `ci/coverage-threshold-*.json` — new code should not decrease CLI or plugin coverage +- The `.claude/skills` symlink points to `.agents/skills` — both paths resolve to the same content diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..1ed3ff8d8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,139 @@ +# CLAUDE.md + +## Project Overview + +NVIDIA NemoClaw is an open-source reference stack for running [OpenClaw](https://openclaw.ai) always-on assistants inside [NVIDIA OpenShell](https://github.com/NVIDIA/OpenShell) sandboxes with NVIDIA inference (Nemotron). It provides CLI tooling, a blueprint for sandbox orchestration, and security hardening. + +**Status:** Alpha (March 2026+). Interfaces may change without notice. + +## Architecture + +| Path | Language | Purpose | +|------|----------|---------| +| `bin/` | JavaScript (CJS) | CLI entry point (`nemoclaw.js`) and library modules | +| `bin/lib/` | JavaScript (CJS) | Core CLI logic: onboard, credentials, inference, policies, preflight, runner | +| `nemoclaw/` | TypeScript | Plugin project (Commander CLI extension for OpenClaw) | +| `nemoclaw/src/blueprint/` | TypeScript | Runner, snapshot, SSRF validation, state management | +| `nemoclaw/src/commands/` | TypeScript | Slash commands, migration state | +| `nemoclaw/src/onboard/` | TypeScript | Onboarding config | +| `nemoclaw-blueprint/` | YAML | Blueprint definition and network policies | +| `scripts/` | Bash/JS/TS | Install helpers, setup, automation, E2E tooling | +| `test/` | JavaScript (ESM) | Root-level integration tests (Vitest) | +| `test/e2e/` | Bash/JS | End-to-end tests (Brev cloud instances) | +| `docs/` | Markdown (MyST) | User-facing docs (Sphinx) | +| `k8s/` | YAML | Kubernetes deployment manifests | + +## Development Commands + +```bash +# Install dependencies +npm install # root deps (OpenClaw + CLI) +cd nemoclaw && npm install && npm run build && cd .. # TypeScript plugin +cd nemoclaw-blueprint && uv sync && cd .. # Python deps + +# Build +cd nemoclaw && npm run build # compile TypeScript plugin +cd nemoclaw && npm run dev # watch mode + +# Test +npm test # root-level tests (Vitest) +cd nemoclaw && npm test # plugin unit tests (Vitest) + +# Lint / check +make check # all linters via prek (pre-commit hooks) +npx prek run --all-files # same as make check +npm run typecheck:cli # type-check CLI (bin/, scripts/) + +# Format +make format # auto-format TypeScript + +# Docs +make docs # build docs (Sphinx/MyST) +make docs-live # serve locally with auto-rebuild +``` + +## Test Structure + +- **Root tests** (`test/*.test.js`): Integration tests run via Vitest (ESM) +- **Plugin tests** (`nemoclaw/src/**/*.test.ts`): Unit tests co-located with source +- **E2E tests** (`test/e2e/`): Cloud-based E2E on Brev instances, triggered only when `BREV_API_TOKEN` is set + +Vitest config (`vitest.config.ts`) defines three projects: `cli`, `plugin`, and `e2e-brev`. + +## Code Style and Conventions + +### Commit Messages + +Conventional Commits required. Enforced by commitlint via prek `commit-msg` hook. + +```text +(): +``` + +Types: `feat`, `fix`, `docs`, `chore`, `refactor`, `test`, `ci`, `perf`, `merge` + +### SPDX Headers + +Every source file must include an SPDX license header. The pre-commit hook auto-inserts them: + +```javascript +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +``` + +For shell scripts use `#` comments. For Markdown use HTML comments. + +### JavaScript + +- `bin/` and `scripts/`: **CommonJS** (`require`/`module.exports`), Node.js 22.16+ +- `test/`: **ESM** (`import`/`export`) +- ESLint config in `eslint.config.mjs` +- Cyclomatic complexity limit: 20 (ratcheting down to 15) +- Unused vars pattern: prefix with `_` + +### TypeScript + +- Plugin code in `nemoclaw/src/` with its own ESLint config +- CLI type-checking via `tsconfig.cli.json` +- Plugin type-checking via `nemoclaw/tsconfig.json` + +### Shell Scripts + +- ShellCheck enforced (`.shellcheckrc` at root) +- `shfmt` for formatting +- All scripts must have shebangs and be executable + +### No External Project Links + +Do not add links to third-party code repositories, community collections, or unofficial resources. Links to official tool documentation (Node.js, Python, uv) are acceptable. + +## Git Hooks (prek) + +All hooks managed by [prek](https://prek.j178.dev/) (installed via `npm install`): + +| Hook | What runs | +|------|-----------| +| **pre-commit** | File fixers, formatters, linters, Vitest (plugin) | +| **commit-msg** | commitlint (Conventional Commits) | +| **pre-push** | TypeScript type check (tsc --noEmit for plugin, JS, CLI) | + +## Documentation + +- Source of truth: `docs/` directory +- `.agents/skills/nemoclaw-*/*.md` is **autogenerated** — never edit directly +- After changing docs, regenerate skills: + + ```bash + python3 scripts/docs-to-skills.py docs/ .agents/skills/ --prefix nemoclaw + ``` + +- Follow style guide in `docs/CONTRIBUTING.md` + +## PR Requirements + +- Create feature branch from `main` +- Run `make check` and `npm test` before submitting +- Follow PR template (`.github/PULL_REQUEST_TEMPLATE.md`) +- Update docs for any user-facing behavior changes +- No secrets, API keys, or credentials committed +- Limit open PRs to fewer than 10 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b1950609c..af9010254 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,7 +14,7 @@ Open an issue when you encounter one of the following situations. Install the following before you begin. -- Node.js 20+ and npm 10+ +- Node.js 22.16+ and npm 10+ - Python 3.11+ (for blueprint and documentation builds) - Docker (running) - [uv](https://docs.astral.sh/uv/) (for Python dependency management) @@ -45,6 +45,12 @@ npm run build # one-time compile npm run dev # watch mode ``` +The CLI (`bin/`, `scripts/`) is type-checked separately: + +```bash +npm run typecheck:cli # or: npx tsc -p tsconfig.cli.json +``` + ## Main Tasks These are the primary `make` and `npm` targets for day-to-day development: @@ -54,6 +60,7 @@ These are the primary `make` and `npm` targets for day-to-day development: | `make check` | Run all linters (TypeScript + Python) | | `make lint` | Same as `make check` | | `make format` | Auto-format TypeScript and Python source | +| `npm run typecheck:cli` | Type-check CLI TypeScript (`bin/`, `scripts/`) | | `npm test` | Run root-level tests (`test/*.test.js`) | | `cd nemoclaw && npm test` | Run plugin unit tests (Vitest) | | `make docs` | Build documentation (Sphinx/MyST) | @@ -68,7 +75,7 @@ All git hooks are managed by [prek](https://prek.j178.dev/), a fast, single-bina |------|-----------| | **pre-commit** | File fixers, formatters, linters, Vitest (plugin) | | **commit-msg** | commitlint (Conventional Commits) | -| **pre-push** | TypeScript type check (`tsc --noEmit`), Pyright (Python) | +| **pre-push** | TypeScript type check (`tsc --noEmit` for plugin, JS, and CLI) | For a full manual check: `npx prek run --all-files`. For scoped runs: `npx prek run --from-ref --to-ref HEAD`. @@ -89,6 +96,14 @@ The repository is organized as follows. | `test/` | Root-level integration tests | | `docs/` | User-facing documentation (Sphinx/MyST) | +## Language Policy + +All new source files must be TypeScript. Do not add new `.js` files to the project. When modifying an existing JavaScript file, prefer migrating it to TypeScript in the same PR. + +Existing JavaScript in `bin/` and `scripts/` is being incrementally migrated (see `src/lib/` for completed migrations). Tests in `test/` may remain ESM JavaScript for now but new test files should use TypeScript where practical. + +Shell scripts (`scripts/*.sh`) must pass ShellCheck and use `shfmt` formatting. + ## Documentation If your change affects user-facing behavior (new commands, changed defaults, new features, bug fixes that contradict existing docs), update the relevant pages under `docs/` in the same PR. @@ -106,42 +121,49 @@ See [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md) for the full style guide and wr ### Doc-to-Skills Pipeline -Always edit pages in `docs/`. Never edit files under `.agents/skills/docs/` — that entire directory is autogenerated by the script below and your changes will be overwritten on the next run. +The `docs/` directory is the source of truth for user-facing documentation. +The script `scripts/docs-to-skills.py` converts doc pages into agent skills under `.agents/skills/`. +These generated skills let AI agents answer user questions and walk through procedures without reading raw doc pages. -The `docs/` directory is the source of truth for user-facing documentation. The script `scripts/docs-to-skills.py` converts those pages into agent skills stored in `.agents/skills/docs/`. These generated skills let AI agents answer user questions and walk through procedures without reading raw doc pages. +Always edit pages in `docs/`. +Never edit generated skill files under `.agents/skills/nemoclaw-*/` — your changes will be overwritten on the next run. -After changing any page in `docs/`, regenerate the skills. Run the canonical command from the repo root: +After changing any page in `docs/`, regenerate the skills from the repo root: ```bash -python scripts/docs-to-skills.py docs/ .agents/skills/docs/ --prefix nemoclaw +python scripts/docs-to-skills.py docs/ .agents/skills/ --prefix nemoclaw ``` -Always use this exact output path and prefix so skill names and locations stay consistent across the project. +Always use this exact output path (`.agents/skills/`) and prefix (`nemoclaw`) so skill names and locations stay consistent. + +Preview what would change before writing files: + +```bash +python scripts/docs-to-skills.py docs/ .agents/skills/ --prefix nemoclaw --dry-run +``` -Useful flags: +Other useful flags: | Flag | Purpose | |------|---------| -| `--dry-run` | Preview what would be generated without writing files. | | `--strategy ` | Grouping strategy: `smart` (default), `grouped`, or `individual`. | | `--name-map CAT=NAME` | Override a generated skill name (e.g. `--name-map about=overview`). | | `--exclude ` | Skip specific files (e.g. `--exclude "release-notes.md"`). | -The generated `.agents/skills/docs/` directory is committed to the repo but is entirely autogenerated. Do not hand-edit any file under it — edit the source page in `docs/` and re-run the script instead. The one exception is the `## Gotchas` section at the bottom of each generated `SKILL.md`, which is reserved for project-specific notes you add manually and is preserved across regenerations. - #### Generated skill structure Each skill directory contains: ```text -.agents/skills/docs// +.agents/skills// ├── SKILL.md # Frontmatter + procedures + related skills └── references/ # Detailed concept and reference content (loaded on demand) ├── .md └── .md ``` -The `references/` directory holds full-length content that agents load only when needed (progressive disclosure). The `SKILL.md` itself stays concise — under 500 lines — so agents can read it quickly. +Agents load the `references/` directory only when needed (progressive disclosure). +The `SKILL.md` itself stays under 500 lines so agents can read it quickly. ## Pull Requests diff --git a/Dockerfile b/Dockerfile index 62e1dddf6..2c8e59491 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,15 @@ # NemoClaw sandbox image — OpenClaw + NemoClaw plugin inside OpenShell +# +# Layers PR-specific code (plugin, blueprint, config, startup script) on top +# of the pre-built base image from GHCR. The base image contains all the +# expensive, rarely-changing layers (apt, gosu, users, openclaw CLI). +# +# For local builds without GHCR access, build the base first: +# docker build -f Dockerfile.base -t ghcr.io/nvidia/nemoclaw/sandbox-base:latest . + +# Global ARG — must be declared before the first FROM to be visible +# to all FROM directives. Can be overridden via --build-arg. +ARG BASE_IMAGE=ghcr.io/nvidia/nemoclaw/sandbox-base:latest # Stage 1: Build TypeScript plugin from source FROM node:22-slim@sha256:4f77a690f2f8946ab16fe1e791a3ac0667ae1c3575c3e4d0d4589e9ed5bfaf3d AS builder @@ -7,85 +18,24 @@ COPY nemoclaw/src/ /opt/nemoclaw/src/ WORKDIR /opt/nemoclaw RUN npm install && npm run build -# Stage 2: Runtime image -FROM node:22-slim@sha256:4f77a690f2f8946ab16fe1e791a3ac0667ae1c3575c3e4d0d4589e9ed5bfaf3d +# Stage 2: Runtime image — pull cached base from GHCR +FROM ${BASE_IMAGE} -ENV DEBIAN_FRONTEND=noninteractive - -RUN apt-get update && apt-get install -y --no-install-recommends \ - python3=3.11.2-1+b1 \ - python3-pip=23.0.1+dfsg-1 \ - python3-venv=3.11.2-1+b1 \ - curl=7.88.1-10+deb12u14 \ - git=1:2.39.5-0+deb12u3 \ - ca-certificates=20230311+deb12u1 \ - iproute2=6.1.0-3 \ +# Harden: remove unnecessary build tools and network probes from base image (#830) +RUN (apt-get remove --purge -y gcc gcc-12 g++ g++-12 cpp cpp-12 make \ + netcat-openbsd netcat-traditional ncat 2>/dev/null || true) \ + && apt-get autoremove --purge -y \ && rm -rf /var/lib/apt/lists/* -# gosu for privilege separation (gateway vs sandbox user). -# Install from GitHub release with checksum verification instead of -# Debian bookworm's ancient 1.14 (2020). Pinned to 1.19 (2025-09). -# hadolint ignore=DL4006 -RUN arch="$(dpkg --print-architecture)" \ - && case "$arch" in \ - amd64) gosu_asset="gosu-amd64"; gosu_sha256="52c8749d0142edd234e9d6bd5237dff2d81e71f43537e2f4f66f75dd4b243dd0" ;; \ - arm64) gosu_asset="gosu-arm64"; gosu_sha256="3a8ef022d82c0bc4a98bcb144e77da714c25fcfa64dccc57f6aba7ae47ff1a44" ;; \ - *) echo "Unsupported architecture for gosu: $arch" >&2; exit 1 ;; \ - esac \ - && curl -fsSL -o /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/1.19/${gosu_asset}" \ - && echo "${gosu_sha256} /usr/local/bin/gosu" | sha256sum -c - \ - && chmod +x /usr/local/bin/gosu \ - && gosu --version - -# Create sandbox user (matches OpenShell convention) and gateway user. -# The gateway runs as 'gateway' so the 'sandbox' user (agent) cannot -# kill it or restart it with a tampered HOME/config. -RUN groupadd -r gateway && useradd -r -g gateway -d /sandbox -s /usr/sbin/nologin gateway \ - && groupadd -r sandbox && useradd -r -g sandbox -d /sandbox -s /bin/bash sandbox \ - && mkdir -p /sandbox/.nemoclaw \ - && chown -R sandbox:sandbox /sandbox - -# Split .openclaw into immutable config dir + writable state dir. -# The policy makes /sandbox/.openclaw read-only via Landlock, so the agent -# cannot modify openclaw.json, auth tokens, or CORS settings. Writable -# state (agents, plugins, etc.) lives in .openclaw-data, reached via symlinks. -# Ref: https://github.com/NVIDIA/NemoClaw/issues/514 -RUN mkdir -p /sandbox/.openclaw-data/agents/main/agent \ - /sandbox/.openclaw-data/extensions \ - /sandbox/.openclaw-data/workspace \ - /sandbox/.openclaw-data/skills \ - /sandbox/.openclaw-data/hooks \ - /sandbox/.openclaw-data/identity \ - /sandbox/.openclaw-data/devices \ - /sandbox/.openclaw-data/canvas \ - /sandbox/.openclaw-data/cron \ - && mkdir -p /sandbox/.openclaw \ - && ln -s /sandbox/.openclaw-data/agents /sandbox/.openclaw/agents \ - && ln -s /sandbox/.openclaw-data/extensions /sandbox/.openclaw/extensions \ - && ln -s /sandbox/.openclaw-data/workspace /sandbox/.openclaw/workspace \ - && ln -s /sandbox/.openclaw-data/skills /sandbox/.openclaw/skills \ - && ln -s /sandbox/.openclaw-data/hooks /sandbox/.openclaw/hooks \ - && ln -s /sandbox/.openclaw-data/identity /sandbox/.openclaw/identity \ - && ln -s /sandbox/.openclaw-data/devices /sandbox/.openclaw/devices \ - && ln -s /sandbox/.openclaw-data/canvas /sandbox/.openclaw/canvas \ - && ln -s /sandbox/.openclaw-data/cron /sandbox/.openclaw/cron \ - && touch /sandbox/.openclaw-data/update-check.json \ - && ln -s /sandbox/.openclaw-data/update-check.json /sandbox/.openclaw/update-check.json \ - && chown -R sandbox:sandbox /sandbox/.openclaw /sandbox/.openclaw-data - -# Install OpenClaw CLI + PyYAML for inline Python scripts in e2e tests -RUN npm install -g openclaw@2026.3.11 \ - && pip3 install --no-cache-dir --break-system-packages "pyyaml==6.0.3" - # Copy built plugin and blueprint into the sandbox COPY --from=builder /opt/nemoclaw/dist/ /opt/nemoclaw/dist/ COPY nemoclaw/openclaw.plugin.json /opt/nemoclaw/ -COPY nemoclaw/package.json /opt/nemoclaw/ +COPY nemoclaw/package.json nemoclaw/package-lock.json /opt/nemoclaw/ COPY nemoclaw-blueprint/ /opt/nemoclaw-blueprint/ # Install runtime dependencies only (no devDependencies, no build step) WORKDIR /opt/nemoclaw -RUN npm install --omit=dev +RUN npm ci --omit=dev # Set up blueprint for local resolution RUN mkdir -p /sandbox/.nemoclaw/blueprints/0.1.0 \ @@ -93,7 +43,7 @@ RUN mkdir -p /sandbox/.nemoclaw/blueprints/0.1.0 \ # Copy startup script COPY scripts/nemoclaw-start.sh /usr/local/bin/nemoclaw-start -RUN chmod +x /usr/local/bin/nemoclaw-start +RUN chmod 755 /usr/local/bin/nemoclaw-start # Build args for config that varies per deployment. # nemoclaw onboard passes these at image build time. @@ -104,6 +54,9 @@ ARG CHAT_UI_URL=http://127.0.0.1:18789 ARG NEMOCLAW_INFERENCE_BASE_URL=https://inference.local/v1 ARG NEMOCLAW_INFERENCE_API=openai-completions ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30= +# Set to "1" to disable device-pairing auth (development/headless only). +# Default: "0" (device auth enabled — secure by default). +ARG NEMOCLAW_DISABLE_DEVICE_AUTH=0 # Unique per build to ensure each image gets a fresh auth token. # Pass --build-arg NEMOCLAW_BUILD_ID=$(date +%s) to bust the cache. ARG NEMOCLAW_BUILD_ID=default @@ -117,7 +70,8 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ CHAT_UI_URL=${CHAT_UI_URL} \ NEMOCLAW_INFERENCE_BASE_URL=${NEMOCLAW_INFERENCE_BASE_URL} \ NEMOCLAW_INFERENCE_API=${NEMOCLAW_INFERENCE_API} \ - NEMOCLAW_INFERENCE_COMPAT_B64=${NEMOCLAW_INFERENCE_COMPAT_B64} + NEMOCLAW_INFERENCE_COMPAT_B64=${NEMOCLAW_INFERENCE_COMPAT_B64} \ + NEMOCLAW_DISABLE_DEVICE_AUTH=${NEMOCLAW_DISABLE_DEVICE_AUTH} WORKDIR /sandbox USER sandbox @@ -141,6 +95,8 @@ parsed = urlparse(chat_ui_url); \ chat_origin = f'{parsed.scheme}://{parsed.netloc}' if parsed.scheme and parsed.netloc else 'http://127.0.0.1:18789'; \ origins = ['http://127.0.0.1:18789']; \ origins = list(dict.fromkeys(origins + [chat_origin])); \ +disable_device_auth = os.environ.get('NEMOCLAW_DISABLE_DEVICE_AUTH', '') == '1'; \ +allow_insecure = parsed.scheme == 'http'; \ providers = { \ provider_key: { \ 'baseUrl': inference_base_url, \ @@ -156,8 +112,8 @@ config = { \ 'gateway': { \ 'mode': 'local', \ 'controlUi': { \ - 'allowInsecureAuth': True, \ - 'dangerouslyDisableDeviceAuth': True, \ + 'allowInsecureAuth': allow_insecure, \ + 'dangerouslyDisableDeviceAuth': disable_device_auth, \ 'allowedOrigins': origins, \ }, \ 'trustedProxies': ['127.0.0.1', '::1'], \ @@ -199,4 +155,4 @@ RUN sha256sum /sandbox/.openclaw/openclaw.json > /sandbox/.openclaw/.config-hash # Entrypoint runs as root to start the gateway as the gateway user, # then drops to sandbox for agent commands. See nemoclaw-start.sh. ENTRYPOINT ["/usr/local/bin/nemoclaw-start"] -CMD [] +CMD ["/bin/bash"] diff --git a/Dockerfile.base b/Dockerfile.base new file mode 100644 index 000000000..3fd658485 --- /dev/null +++ b/Dockerfile.base @@ -0,0 +1,123 @@ +# NemoClaw sandbox base image — expensive, rarely-changing layers. +# +# Contains: node:22-slim, apt packages, gosu, user/group setup, +# .openclaw directory structure, OpenClaw CLI, and PyYAML. +# +# Built on main merges and pushed to GHCR. The production Dockerfile +# layers PR-specific code (plugin, blueprint, config) on top. +# +# ── Why these layers are safe to cache ────────────────────────────────── +# +# Everything in this file is either pinned to an exact version or is +# structural (users, directories, symlinks) that doesn't depend on +# NemoClaw application code. Specifically: +# +# node:22-slim — pinned by sha256 digest, checked weekly by +# docker-pin-check.yaml +# apt packages — pinned to exact Debian bookworm versions +# gosu 1.19 — pinned release + per-arch sha256 checksum +# gateway/sandbox — OS users and groups; names and UIDs are a +# users stable contract with OpenShell +# .openclaw dirs — directory structure + symlinks are dictated by +# + symlinks the OpenClaw CLI layout; new dirs are additive +# (add them here and rebuild) +# openclaw CLI — pinned to exact npm version (e.g., 2026.3.11) +# pyyaml — pinned to exact pip version (6.0.3) +# +# Nothing here references NemoClaw plugin source, blueprint files, +# startup scripts, or build-time config (model, provider, auth token). +# Those all live in the production Dockerfile's thin top layers. +# +# ── When to rebuild ───────────────────────────────────────────────────── +# +# The base-image.yaml workflow rebuilds automatically on main merges that +# touch this file. You need to edit this file (triggering a rebuild) when: +# +# 1. OpenClaw CLI version bump — change the openclaw@version below +# 2. New apt package needed — add it to the apt-get install list +# 3. gosu upgrade — update URL, checksum, and version +# 4. node:22-slim digest rotated — update-docker-pin.sh updates both +# Dockerfile and Dockerfile.base +# 5. New .openclaw subdirectory — add mkdir + symlink below +# 6. PyYAML or other pip dep bump — change the version below +# +# For ad-hoc rebuilds (e.g., security patch), use workflow_dispatch on +# the base-image workflow. +# +# Expected rebuild frequency: every few weeks to months, driven mostly +# by OpenClaw CLI version bumps or the weekly docker-pin-check. +# ──────────────────────────────────────────────────────────────────────── + +FROM node:22-slim@sha256:4f77a690f2f8946ab16fe1e791a3ac0667ae1c3575c3e4d0d4589e9ed5bfaf3d + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3=3.11.2-1+b1 \ + python3-pip=23.0.1+dfsg-1 \ + python3-venv=3.11.2-1+b1 \ + curl=7.88.1-10+deb12u14 \ + git=1:2.39.5-0+deb12u3 \ + ca-certificates=20230311+deb12u1 \ + iproute2=6.1.0-3 \ + iptables=1.8.9-2 \ + libcap2-bin=1:2.66-4+deb12u2+b2 \ + && rm -rf /var/lib/apt/lists/* + +# gosu for privilege separation (gateway vs sandbox user). +# Install from GitHub release with checksum verification instead of +# Debian bookworm's ancient 1.14 (2020). Pinned to 1.19 (2025-09). +# hadolint ignore=DL4006 +RUN arch="$(dpkg --print-architecture)" \ + && case "$arch" in \ + amd64) gosu_asset="gosu-amd64"; gosu_sha256="52c8749d0142edd234e9d6bd5237dff2d81e71f43537e2f4f66f75dd4b243dd0" ;; \ + arm64) gosu_asset="gosu-arm64"; gosu_sha256="3a8ef022d82c0bc4a98bcb144e77da714c25fcfa64dccc57f6aba7ae47ff1a44" ;; \ + *) echo "Unsupported architecture for gosu: $arch" >&2; exit 1 ;; \ + esac \ + && curl -fsSL -o /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/1.19/${gosu_asset}" \ + && echo "${gosu_sha256} /usr/local/bin/gosu" | sha256sum -c - \ + && chmod +x /usr/local/bin/gosu \ + && gosu --version + +# Create sandbox user (matches OpenShell convention) and gateway user. +# The gateway runs as 'gateway' so the 'sandbox' user (agent) cannot +# kill it or restart it with a tampered HOME/config. +RUN groupadd -r gateway && useradd -r -g gateway -d /sandbox -s /usr/sbin/nologin gateway \ + && groupadd -r sandbox && useradd -r -g sandbox -d /sandbox -s /bin/bash sandbox \ + && mkdir -p /sandbox/.nemoclaw \ + && chown -R sandbox:sandbox /sandbox + +# Split .openclaw into immutable config dir + writable state dir. +# The policy makes /sandbox/.openclaw read-only via Landlock, so the agent +# cannot modify openclaw.json, auth tokens, or CORS settings. Writable +# state (agents, plugins, etc.) lives in .openclaw-data, reached via symlinks. +# Ref: https://github.com/NVIDIA/NemoClaw/issues/514 +RUN mkdir -p /sandbox/.openclaw-data/agents/main/agent \ + /sandbox/.openclaw-data/extensions \ + /sandbox/.openclaw-data/workspace \ + /sandbox/.openclaw-data/skills \ + /sandbox/.openclaw-data/hooks \ + /sandbox/.openclaw-data/identity \ + /sandbox/.openclaw-data/devices \ + /sandbox/.openclaw-data/canvas \ + /sandbox/.openclaw-data/cron \ + /sandbox/.openclaw-data/memory \ + && mkdir -p /sandbox/.openclaw \ + && ln -s /sandbox/.openclaw-data/agents /sandbox/.openclaw/agents \ + && ln -s /sandbox/.openclaw-data/extensions /sandbox/.openclaw/extensions \ + && ln -s /sandbox/.openclaw-data/workspace /sandbox/.openclaw/workspace \ + && ln -s /sandbox/.openclaw-data/skills /sandbox/.openclaw/skills \ + && ln -s /sandbox/.openclaw-data/hooks /sandbox/.openclaw/hooks \ + && ln -s /sandbox/.openclaw-data/identity /sandbox/.openclaw/identity \ + && ln -s /sandbox/.openclaw-data/devices /sandbox/.openclaw/devices \ + && ln -s /sandbox/.openclaw-data/canvas /sandbox/.openclaw/canvas \ + && ln -s /sandbox/.openclaw-data/cron /sandbox/.openclaw/cron \ + && ln -s /sandbox/.openclaw-data/memory /sandbox/.openclaw/memory \ + && touch /sandbox/.openclaw-data/update-check.json \ + && ln -s /sandbox/.openclaw-data/update-check.json /sandbox/.openclaw/update-check.json \ + && chown -R sandbox:sandbox /sandbox/.openclaw /sandbox/.openclaw-data + +# Install OpenClaw CLI + PyYAML for inline Python scripts in e2e tests. +# When bumping the openclaw version, rebuild this base image. +RUN npm install -g openclaw@2026.3.11 \ + && pip3 install --no-cache-dir --break-system-packages "pyyaml==6.0.3" diff --git a/Makefile b/Makefile index 77f0de15f..cad420be0 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,10 @@ lint: check lint-ts: cd nemoclaw && npm run check -format: format-ts +format: format-ts format-cli + +format-cli: + npx prettier --write 'bin/**/*.js' 'test/**/*.js' format-ts: cd nemoclaw && npm run lint:fix && npm run format diff --git a/README.md b/README.md index 38bdb4c23..570e692a9 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,15 @@ -# NVIDIA NemoClaw: Reference Stack for Running OpenClaw in OpenShell +# 🦞 NVIDIA NemoClaw: Reference Stack for Running OpenClaw in OpenShell [![License](https://img.shields.io/badge/License-Apache_2.0-blue)](https://github.com/NVIDIA/NemoClaw/blob/main/LICENSE) [![Security Policy](https://img.shields.io/badge/Security-Report%20a%20Vulnerability-red)](https://github.com/NVIDIA/NemoClaw/blob/main/SECURITY.md) [![Project Status](https://img.shields.io/badge/status-alpha-orange)](https://github.com/NVIDIA/NemoClaw/blob/main/docs/about/release-notes.md) +[![Discord](https://img.shields.io/badge/Discord-Join-7289da)](https://discord.gg/XFpfPv9Uvx) NVIDIA NemoClaw is an open source reference stack that simplifies running [OpenClaw](https://openclaw.ai) always-on assistants more safely. It installs the [NVIDIA OpenShell](https://github.com/NVIDIA/OpenShell) runtime, part of NVIDIA Agent Toolkit, which provides additional security for running autonomous agents. -It also includes open source models such as [NVIDIA Nemotron](https://build.nvidia.com). > **Alpha software** @@ -20,21 +20,17 @@ It also includes open source models such as [NVIDIA Nemotron](https://build.nvid > The project is shared to gather feedback and enable early experimentation. > We welcome issues and discussion from the community while the project evolves. ---- +NemoClaw adds guided onboarding, a hardened blueprint, state management, messaging bridges, routed inference, and layered protection on top of the [NVIDIA OpenShell](https://github.com/NVIDIA/OpenShell) runtime. For the full feature list, refer to [Overview](https://docs.nvidia.com/nemoclaw/latest/about/overview.html). For the system diagram, component model, and blueprint lifecycle, refer to [How It Works](https://docs.nvidia.com/nemoclaw/latest/about/how-it-works.html) and [Architecture](https://docs.nvidia.com/nemoclaw/latest/reference/architecture.html). -## Quick Start +## Getting Started -Follow these steps to get started with NemoClaw and your first sandboxed OpenClaw agent. - -> **ℹ️ Note** -> -> NemoClaw creates a fresh OpenClaw instance inside the sandbox during onboarding. +Follow these steps to install NemoClaw and run your first sandboxed OpenClaw agent. ### Prerequisites -Check the prerequisites before you start to ensure you have the necessary software and hardware to run NemoClaw. +Before getting started, check the prerequisites to ensure you have the necessary software and hardware to run NemoClaw. #### Hardware @@ -51,29 +47,30 @@ The sandbox image is approximately 2.4 GB compressed. During image push, the Doc | Dependency | Version | |------------|----------------------------------| | Linux | Ubuntu 22.04 LTS or later | -| Node.js | 20 or later | +| Node.js | 22.16 or later | | npm | 10 or later | | Container runtime | Supported runtime installed and running | | [OpenShell](https://github.com/NVIDIA/OpenShell) | Installed | -#### Container Runtime Support +#### Container Runtimes | Platform | Supported runtimes | Notes | |----------|--------------------|-------| -| Linux | Docker | Primary supported path today | -| macOS (Apple Silicon) | Colima, Docker Desktop | Recommended runtimes for supported macOS setups | -| macOS | Podman | Not supported yet. NemoClaw currently depends on OpenShell support for Podman on macOS. | -| Windows WSL | Docker Desktop (WSL backend) | Supported target path | - -> **💡 Tip** -> -> For DGX Spark, follow the [DGX Spark setup guide](https://github.com/NVIDIA/NemoClaw/blob/main/spark-install.md). It covers Spark-specific prerequisites, such as cgroup v2 and Docker configuration, before running the standard installer. +| Linux | Docker | Primary supported path. | +| macOS (Apple Silicon) | Colima, Docker Desktop | Install Xcode Command Line Tools (`xcode-select --install`) and start the runtime before running the installer. | +| macOS (Intel) | Podman | Not supported yet. Depends on OpenShell support for Podman on macOS. | +| Windows WSL | Docker Desktop (WSL backend) | Supported target path. | +| DGX Spark | Docker | Refer to the [DGX Spark setup guide](https://github.com/NVIDIA/NemoClaw/blob/main/spark-install.md) for cgroup v2 and Docker configuration. | ### Install NemoClaw and Onboard OpenClaw Agent Download and run the installer script. The script installs Node.js if it is not already present, then runs the guided onboard wizard to create a sandbox, configure inference, and apply security policies. +> **ℹ️ Note** +> +> NemoClaw creates a fresh OpenClaw instance inside the sandbox during the onboarding process. + ```bash curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash ``` @@ -100,177 +97,103 @@ Logs: nemoclaw my-assistant logs --follow Connect to the sandbox, then chat with the agent through the TUI or the CLI. -#### Connect to the Sandbox - -Run the following command to connect to the sandbox: - ```bash nemoclaw my-assistant connect ``` -This connects you to the sandbox shell `sandbox@my-assistant:~$` where you can run `openclaw` commands. - -#### OpenClaw TUI - -In the sandbox shell, run the following command to open the OpenClaw TUI, which opens an interactive chat interface. +In the sandbox shell, open the OpenClaw terminal UI and start a chat: ```bash openclaw tui ``` -Send a test message to the agent and verify you receive a response. - -> **ℹ️ Note** -> -> The TUI is best for interactive back-and-forth. If you need the full text of a long response such as a large code generation output, use the CLI instead. - -#### OpenClaw CLI - -In the sandbox shell, run the following command to send a single message and print the response: +Alternatively, send a single message and print the response: ```bash openclaw agent --agent main --local -m "hello" --session-id test ``` -This prints the complete response directly in the terminal and avoids relying on the TUI view for long output. - ### Uninstall -To remove NemoClaw and all resources created during setup, in the terminal outside the sandbox, run: +To remove NemoClaw and all resources created during setup, run the uninstall script: ```bash curl -fsSL https://raw.githubusercontent.com/NVIDIA/NemoClaw/refs/heads/main/uninstall.sh | bash ``` -The script removes sandboxes, the NemoClaw gateway and providers, related Docker images and containers, local state directories, and the global `nemoclaw` npm package. It does not remove shared system tooling such as Docker, Node.js, npm, or Ollama. - | Flag | Effect | |--------------------|-----------------------------------------------------| | `--yes` | Skip the confirmation prompt. | | `--keep-openshell` | Leave the `openshell` binary installed. | | `--delete-models` | Also remove NemoClaw-pulled Ollama models. | -For example, to skip the confirmation prompt: - -```bash -curl -fsSL https://raw.githubusercontent.com/NVIDIA/NemoClaw/refs/heads/main/uninstall.sh | bash -s -- --yes -``` +For troubleshooting installation or onboarding issues, see the [Troubleshooting guide](https://docs.nvidia.com/nemoclaw/latest/reference/troubleshooting.html). ---- - -## How It Works +## Documentation -NemoClaw installs the NVIDIA OpenShell runtime, then creates a sandboxed OpenClaw environment where every network request, file access, and inference call is governed by declarative policy. The `nemoclaw` CLI orchestrates the full stack: OpenShell gateway, sandbox, inference provider, and network policy. +Refer to the following pages on the official documentation website for more information on NemoClaw. -| Component | Role | -|------------------|-------------------------------------------------------------------------------------------| -| **Plugin** | TypeScript CLI commands for launch, connect, status, and logs. | -| **Blueprint** | Versioned Python artifact that orchestrates sandbox creation, policy, and inference setup. | -| **Sandbox** | Isolated OpenShell container running OpenClaw with policy-enforced egress and filesystem. | -| **Inference** | Provider-routed model calls, routed through the OpenShell gateway, transparent to the agent. | +| Page | Description | +|------|-------------| +| [Overview](https://docs.nvidia.com/nemoclaw/latest/about/overview.html) | What NemoClaw does and how it fits together. | +| [How It Works](https://docs.nvidia.com/nemoclaw/latest/about/how-it-works.html) | Plugin, blueprint, sandbox lifecycle, and protection layers. | +| [Architecture](https://docs.nvidia.com/nemoclaw/latest/reference/architecture.html) | Plugin structure, blueprint lifecycle, sandbox environment, and host-side state. | +| [Inference Options](https://docs.nvidia.com/nemoclaw/latest/inference/inference-options.html) | Supported providers, validation, and routed inference configuration. | +| [Network Policies](https://docs.nvidia.com/nemoclaw/latest/reference/network-policies.html) | Baseline rules, operator approval flow, and egress control. | +| [Customize Network Policy](https://docs.nvidia.com/nemoclaw/latest/network-policy/customize-network-policy.html) | Static and dynamic policy changes, presets. | +| [Security Best Practices](https://docs.nvidia.com/nemoclaw/latest/security/best-practices.html) | Controls reference, risk framework, and posture profiles for sandbox security. | +| [Sandbox Hardening](https://docs.nvidia.com/nemoclaw/latest/deployment/sandbox-hardening.html) | Container security measures, capability drops, process limits. | +| [CLI Commands](https://docs.nvidia.com/nemoclaw/latest/reference/commands.html) | Full NemoClaw CLI command reference. | +| [Troubleshooting](https://docs.nvidia.com/nemoclaw/latest/reference/troubleshooting.html) | Common issues and resolution steps. | -The blueprint lifecycle follows four stages: resolve the artifact, verify its digest, plan the resources, and apply through the OpenShell CLI. +## Project Structure -When something goes wrong, errors may originate from either NemoClaw or the OpenShell layer underneath. Run `nemoclaw status` for NemoClaw-level health and `openshell sandbox list` to check the underlying sandbox state. +The following directories make up the NemoClaw repository. ---- - -## Inference - -Inference requests from the agent never leave the sandbox directly. OpenShell intercepts every call and routes it to the provider you selected during onboarding. - -Supported non-experimental onboarding paths: - -| Provider | Notes | -|---|---| -| NVIDIA Endpoints | Curated hosted models on `integrate.api.nvidia.com`. | -| OpenAI | Curated GPT models plus `Other...` for manual model entry. | -| Other OpenAI-compatible endpoint | For proxies and compatible gateways. | -| Anthropic | Curated Claude models plus `Other...` for manual model entry. | -| Other Anthropic-compatible endpoint | For Claude proxies and compatible gateways. | -| Google Gemini | Google's OpenAI-compatible endpoint. | - -During onboarding, NemoClaw validates the selected provider and model before it creates the sandbox: - -- OpenAI-compatible providers: tries `/responses` first, then `/chat/completions` -- Anthropic-compatible providers: tries `/v1/messages` -- If validation fails, the wizard prompts you to fix the selection before continuing - -Credentials stay on the host in `~/.nemoclaw/credentials.json`. The sandbox only sees the routed `inference.local` endpoint, not your raw provider key. - -Local Ollama is supported in the standard onboarding flow. Local vLLM remains experimental, and local host-routed inference on macOS still depends on OpenShell host-routing support in addition to the local service itself being reachable on the host. - ---- - -## Protection Layers - -The sandbox starts with a default policy that controls network egress and filesystem access: - -| Layer | What it protects | When it applies | -|------------|-----------------------------------------------------|-----------------------------| -| Network | Blocks unauthorized outbound connections. | Hot-reloadable at runtime. | -| Filesystem | Prevents reads/writes outside `/sandbox` and `/tmp`.| Locked at sandbox creation. | -| Process | Blocks privilege escalation and dangerous syscalls. | Locked at sandbox creation. | -| Inference | Reroutes model API calls to controlled backends. | Hot-reloadable at runtime. | - -When the agent tries to reach an unlisted host, OpenShell blocks the request and surfaces it in the TUI for operator approval. - ---- - -## Configuring Sandbox Policy - -The sandbox policy is defined in a declarative YAML file and enforced by the OpenShell runtime. -NemoClaw ships a default policy in [`nemoclaw-blueprint/policies/openclaw-sandbox.yaml`](https://github.com/NVIDIA/NemoClaw/blob/main/nemoclaw-blueprint/policies/openclaw-sandbox.yaml) that denies all network egress except explicitly listed endpoints. - -Operators can customize the policy in two ways: - -| Method | How | Scope | -|--------|-----|-------| -| **Static** | Edit `openclaw-sandbox.yaml` and re-run `nemoclaw onboard`. | Persists across restarts. | -| **Dynamic** | Run `openshell policy set ` on a running sandbox. | Session only; resets on restart. | - -NemoClaw includes preset policy files for common integrations such as PyPI, Docker Hub, Slack, and Jira in `nemoclaw-blueprint/policies/presets/`. Apply a preset as-is or use it as a starting template. - -NemoClaw is an open project — we are still determining which presets to ship by default. If you have suggestions, please open an [issue](https://github.com/NVIDIA/NemoClaw/issues) or [discussion](https://github.com/NVIDIA/NemoClaw/discussions). - -When the agent attempts to reach an endpoint not covered by the policy, OpenShell blocks the request and surfaces it in the TUI (`openshell term`) for the operator to approve or deny in real time. Approved endpoints persist for the current session only. +```text +NemoClaw/ +├── bin/ # CLI entry point and library modules (CJS) +├── nemoclaw/ # TypeScript plugin (Commander CLI extension) +│ └── src/ +│ ├── blueprint/ # Runner, snapshot, SSRF validation, state +│ ├── commands/ # Slash commands, migration state +│ └── onboard/ # Onboarding config +├── nemoclaw-blueprint/ # Blueprint YAML and network policies +├── scripts/ # Install helpers, setup, automation +├── test/ # Integration and E2E tests +└── docs/ # User-facing docs (Sphinx/MyST) +``` -For step-by-step instructions, see [Customize Network Policy](https://docs.nvidia.com/nemoclaw/latest/network-policy/customize-network-policy.html). For the underlying enforcement details, see the OpenShell [Policy Schema](https://docs.nvidia.com/openshell/latest/reference/policy-schema.html) and [Sandbox Policies](https://docs.nvidia.com/openshell/latest/sandboxes/policies.html) documentation. +## Community ---- +Join the NemoClaw community to ask questions, share feedback, and report issues. -## Key Commands +- [Discord](https://discord.gg/XFpfPv9Uvx) +- [GitHub Discussions](https://github.com/NVIDIA/NemoClaw/discussions) +- [GitHub Issues](https://github.com/NVIDIA/NemoClaw/issues) -### Host commands (`nemoclaw`) +## Contributing -Run these on the host to set up, connect to, and manage sandboxes. +We welcome contributions. See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, coding standards, and the PR process. -| Command | Description | -|--------------------------------------|--------------------------------------------------------| -| `nemoclaw onboard` | Interactive setup wizard: gateway, providers, sandbox. | -| `nemoclaw connect` | Open an interactive shell inside the sandbox. | -| `openshell term` | Launch the OpenShell TUI for monitoring and approvals. | -| `nemoclaw start` / `stop` / `status` | Manage auxiliary services (Telegram bridge, tunnel). | +## Security -See the full [CLI reference](https://docs.nvidia.com/nemoclaw/latest/reference/commands.html) for all commands, flags, and options. +NVIDIA takes security seriously. +If you discover a vulnerability in NemoClaw, **DO NOT open a public issue.** +Use one of the private reporting channels described in [SECURITY.md](SECURITY.md): ---- +- Submit a report through the [NVIDIA Vulnerability Disclosure Program](https://www.nvidia.com/en-us/security/report-vulnerability/). +- Send an email to [psirt@nvidia.com](mailto:psirt@nvidia.com) encrypted with the [NVIDIA PGP key](https://www.nvidia.com/en-us/security/pgp-key). +- Use [GitHub's private vulnerability reporting](https://docs.github.com/en/code-security/how-tos/report-and-fix-vulnerabilities/configure-vulnerability-reporting/configuring-private-vulnerability-reporting-for-a-repository) to submit a report directly on this repository. -## Learn More +For security bulletins and PSIRT policies, visit the [NVIDIA Product Security](https://www.nvidia.com/en-us/security/) portal. -Refer to the documentation for more information on NemoClaw. +## Notice and Disclaimer -- [Overview](https://docs.nvidia.com/nemoclaw/latest/about/overview.html): Learn what NemoClaw does and how it fits together. -- [How It Works](https://docs.nvidia.com/nemoclaw/latest/about/how-it-works.html): Learn about the plugin, blueprint, and sandbox lifecycle. -- [Architecture](https://docs.nvidia.com/nemoclaw/latest/reference/architecture.html): Learn about the plugin structure, blueprint lifecycle, and sandbox environment. -- [Inference Profiles](https://docs.nvidia.com/nemoclaw/latest/reference/inference-profiles.html): Learn how NemoClaw configures routed inference providers. -- [Network Policies](https://docs.nvidia.com/nemoclaw/latest/reference/network-policies.html): Learn about egress control and policy customization. -- [CLI Commands](https://docs.nvidia.com/nemoclaw/latest/reference/commands.html): Learn about the full command reference. -- [Troubleshooting](https://docs.nvidia.com/nemoclaw/latest/reference/troubleshooting.html): Troubleshoot common issues and resolution steps. -- [Discord](https://discord.gg/XFpfPv9Uvx): Join the community for questions and discussion. +This software automatically retrieves, accesses or interacts with external materials. Those retrieved materials are not distributed with this software and are governed solely by separate terms, conditions and licenses. You are solely responsible for finding, reviewing and complying with all applicable terms, conditions, and licenses, and for verifying the security, integrity and suitability of any retrieved materials for your specific use case. This software is provided "AS IS", without warranty of any kind. The author makes no representations or warranties regarding any retrieved materials, and assumes no liability for any losses, damages, liabilities or legal consequences from your use or inability to use this software or any retrieved materials. Use this software and the retrieved materials at your own risk. ## License -This project is licensed under the [Apache License 2.0](LICENSE). +Apache 2.0. See [LICENSE](LICENSE). diff --git a/SECURITY.md b/SECURITY.md index 9dee9356f..daa5ecc0e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,26 +1,58 @@ ## Security -NVIDIA is dedicated to the security and trust of our software products and services, including all source code repositories managed through our organization. +NVIDIA is dedicated to the security and trust of its software products and services, including all source code repositories managed through our organization. -If you need to report a security issue, please use the appropriate contact points outlined below. **Please do not report security vulnerabilities through GitHub.** If a potential security issue is inadvertently reported via a public issue or pull request, NVIDIA maintainers may limit public discussion and redirect the reporter to the appropriate private disclosure channels. +If you need to report a security issue, use the appropriate contact points outlined below. +**DO NOT report security vulnerabilities through public GitHub issues or pull requests.** +If a potential security issue is inadvertently reported through a public channel, NVIDIA maintainers may limit public discussion and redirect the reporter to the appropriate private disclosure channels. -## Reporting Potential Security Vulnerability in an NVIDIA Product +## How to Report a Vulnerability -To report a potential security vulnerability in any NVIDIA product: +Report a potential security vulnerability in NemoClaw or any NVIDIA product through one of the following channels. -- Web: [Security Vulnerability Submission Form](https://www.nvidia.com/object/submit-security-vulnerability.html) -- E-Mail: - - We encourage you to use the following PGP key for secure email communication: [NVIDIA public PGP Key for communication](https://www.nvidia.com/en-us/security/pgp-key) - - Please include the following information: - - Product/Driver name and version/branch that contains the vulnerability - - Type of vulnerability (code execution, denial of service, buffer overflow, etc.) - - Instructions to reproduce the vulnerability - - Proof-of-concept or exploit code - - Potential impact of the vulnerability, including how an attacker could exploit the vulnerability +### NVIDIA Vulnerability Disclosure Program -While NVIDIA currently does not have a bug bounty program, we do offer acknowledgement when an externally reported security issue is addressed under our coordinated vulnerability disclosure policy. Please visit our [Product Security Incident Response Team (PSIRT)](https://www.nvidia.com/en-us/security/psirt-policies/) policies page for more information. +Submit a report through the [NVIDIA Vulnerability Disclosure Program](https://www.nvidia.com/en-us/security/report-vulnerability/). +This is the preferred method for reporting security concerns across all NVIDIA products. + +### Email + +Send an encrypted email to [psirt@nvidia.com](mailto:psirt@nvidia.com). +Use the [NVIDIA public PGP key](https://www.nvidia.com/en-us/security/pgp-key) to encrypt the message. + +### GitHub Private Vulnerability Reporting + +You can use [GitHub's private vulnerability reporting](https://docs.github.com/en/code-security/how-tos/report-and-fix-vulnerabilities/configure-vulnerability-reporting/configuring-private-vulnerability-reporting-for-a-repository) to submit a report directly on this repository. +Navigate to the **Security** tab and select **Report a vulnerability**. + +## What to Include + +Provide as much of the following information as possible: + +- Product name and version or branch that contains the vulnerability. +- Type of vulnerability (code execution, denial of service, buffer overflow, privilege escalation, etc.). +- Step-by-step instructions to reproduce the vulnerability. +- Proof-of-concept or exploit code. +- Potential impact, including how an attacker could exploit the vulnerability. + +Detailed reports help NVIDIA evaluate and address issues faster. + +## What to Expect + +NVIDIA's Product Security Incident Response Team (PSIRT) triages all incoming reports. +After submission: + +1. NVIDIA acknowledges receipt and begins analysis. +2. NVIDIA validates the report and determines severity. +3. NVIDIA develops and tests corrective actions. +4. NVIDIA publishes a security bulletin and releases a fix. + +Visit the [PSIRT Policies](https://www.nvidia.com/en-us/security/) page for details on timelines and acknowledgement practices. + +While NVIDIA does not currently have a public bug bounty program, we do offer acknowledgement when an externally reported security issue is addressed under our coordinated vulnerability disclosure policy. ## NVIDIA Product Security -For all security-related concerns, please visit NVIDIA's Product Security portal at +For security bulletins, PSIRT policies, and all security-related concerns, visit the [NVIDIA Product Security](https://www.nvidia.com/en-us/security/) portal. +Subscribe to notifications on that page to receive alerts when new bulletins are published. diff --git a/bin/lib/chat-filter.js b/bin/lib/chat-filter.js new file mode 100644 index 000000000..b6f63bc46 --- /dev/null +++ b/bin/lib/chat-filter.js @@ -0,0 +1,7 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Thin re-export shim — the implementation lives in src/lib/chat-filter.ts, +// compiled to dist/lib/chat-filter.js. + +module.exports = require("../../dist/lib/chat-filter"); diff --git a/bin/lib/credentials.js b/bin/lib/credentials.js index 67cf01dfe..f2b17caf3 100644 --- a/bin/lib/credentials.js +++ b/bin/lib/credentials.js @@ -3,32 +3,97 @@ const fs = require("fs"); const path = require("path"); +const os = require("os"); const readline = require("readline"); -const { execSync } = require("child_process"); +const { execFileSync } = require("child_process"); -const CREDS_DIR = path.join(process.env.HOME || "/tmp", ".nemoclaw"); -const CREDS_FILE = path.join(CREDS_DIR, "credentials.json"); +const UNSAFE_HOME_PATHS = new Set(["/tmp", "/var/tmp", "/dev/shm", "/"]); + +function resolveHomeDir() { + const raw = process.env.HOME || os.homedir(); + if (!raw) { + throw new Error( + "Cannot determine safe home directory for credential storage. " + + "Set the HOME environment variable to a user-owned directory.", + ); + } + const home = path.resolve(raw); + try { + const real = fs.realpathSync(home); + if (UNSAFE_HOME_PATHS.has(real)) { + throw new Error( + "Cannot store credentials: HOME resolves to '" + + real + + "' which is world-readable. " + + "Set the HOME environment variable to a user-owned directory.", + ); + } + } catch (e) { + if (e.code !== "ENOENT") throw e; + } + if (UNSAFE_HOME_PATHS.has(home)) { + throw new Error( + "Cannot store credentials: HOME resolves to '" + + home + + "' which is world-readable. " + + "Set the HOME environment variable to a user-owned directory.", + ); + } + return home; +} + +let _credsDir = null; +let _credsFile = null; + +function getCredsDir() { + if (!_credsDir) _credsDir = path.join(resolveHomeDir(), ".nemoclaw"); + return _credsDir; +} + +function getCredsFile() { + if (!_credsFile) _credsFile = path.join(getCredsDir(), "credentials.json"); + return _credsFile; +} function loadCredentials() { try { - if (fs.existsSync(CREDS_FILE)) { - return JSON.parse(fs.readFileSync(CREDS_FILE, "utf-8")); + const file = getCredsFile(); + if (fs.existsSync(file)) { + return JSON.parse(fs.readFileSync(file, "utf-8")); } - } catch { /* ignored */ } + } catch { + /* ignored */ + } return {}; } +function normalizeCredentialValue(value) { + if (typeof value !== "string") return ""; + return value.replace(/\r/g, "").trim(); +} + function saveCredential(key, value) { - fs.mkdirSync(CREDS_DIR, { recursive: true, mode: 0o700 }); + const dir = getCredsDir(); + const file = getCredsFile(); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + fs.chmodSync(dir, 0o700); const creds = loadCredentials(); - creds[key] = value; - fs.writeFileSync(CREDS_FILE, JSON.stringify(creds, null, 2), { mode: 0o600 }); + creds[key] = normalizeCredentialValue(value); + fs.writeFileSync(file, JSON.stringify(creds, null, 2), { mode: 0o600 }); + fs.chmodSync(file, 0o600); +} + +function normalizeSecret(value) { + if (value == null) return null; + return String(value).replace(/\r/g, "").trim(); } function getCredential(key) { - if (process.env[key]) return process.env[key]; + if (process.env[key]) return normalizeSecret(process.env[key]); const creds = loadCredentials(); - return creds[key] || null; + const raw = creds[key]; + if (raw == null) return null; + return normalizeSecret(raw) || null; } function promptSecret(question) { @@ -73,7 +138,10 @@ function promptSecret(question) { } if (ch === "\u0008" || ch === "\u007f") { - answer = answer.slice(0, -1); + if (answer.length > 0) { + answer = answer.slice(0, -1); + output.write("\b \b"); + } continue; } @@ -91,6 +159,7 @@ function promptSecret(question) { if (ch >= " ") { answer += ch; + output.write("*"); } } } @@ -125,7 +194,10 @@ function prompt(question, opts = {}) { return; } const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); - rl.question(question, (answer) => { + let finished = false; + function finish(fn, value) { + if (finished) return; + finished = true; rl.close(); if (!process.stdin.isTTY) { if (typeof process.stdin.pause === "function") { @@ -135,7 +207,15 @@ function prompt(question, opts = {}) { process.stdin.unref(); } } - resolve(answer.trim()); + fn(value); + } + rl.on("SIGINT", () => { + const err = Object.assign(new Error("Prompt interrupted"), { code: "SIGINT" }); + finish(reject, err); + process.kill(process.pid, "SIGINT"); + }); + rl.question(question, (answer) => { + finish(resolve, answer.trim()); }); }); } @@ -158,11 +238,20 @@ async function ensureApiKey() { console.log(" └─────────────────────────────────────────────────────────────────┘"); console.log(""); - key = await prompt(" NVIDIA API Key: ", { secret: true }); + while (true) { + key = normalizeCredentialValue(await prompt(" NVIDIA API Key: ", { secret: true })); - if (!key || !key.startsWith("nvapi-")) { - console.error(" Invalid key. Must start with nvapi-"); - process.exit(1); + if (!key) { + console.error(" NVIDIA API Key is required."); + continue; + } + + if (!key.startsWith("nvapi-")) { + console.error(" Invalid key. Must start with nvapi-"); + continue; + } + + break; } saveCredential("NVIDIA_API_KEY", key); @@ -174,7 +263,10 @@ async function ensureApiKey() { function isRepoPrivate(repo) { try { - const json = execSync(`gh api repos/${repo} --jq .private 2>/dev/null`, { encoding: "utf-8" }).trim(); + const json = execFileSync("gh", ["api", `repos/${repo}`, "--jq", ".private"], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); return json === "true"; } catch { return false; @@ -189,12 +281,17 @@ async function ensureGithubToken() { } try { - token = execSync("gh auth token 2>/dev/null", { encoding: "utf-8" }).trim(); + token = execFileSync("gh", ["auth", "token"], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); if (token) { process.env.GITHUB_TOKEN = token; return; } - } catch { /* ignored */ } + } catch { + /* ignored */ + } console.log(""); console.log(" ┌──────────────────────────────────────────────────┐"); @@ -219,10 +316,9 @@ async function ensureGithubToken() { console.log(""); } -module.exports = { - CREDS_DIR, - CREDS_FILE, +const exports_ = { loadCredentials, + normalizeCredentialValue, saveCredential, getCredential, prompt, @@ -230,3 +326,8 @@ module.exports = { ensureGithubToken, isRepoPrivate, }; + +Object.defineProperty(exports_, "CREDS_DIR", { get: getCredsDir, enumerable: true }); +Object.defineProperty(exports_, "CREDS_FILE", { get: getCredsFile, enumerable: true }); + +module.exports = exports_; diff --git a/bin/lib/debug.js b/bin/lib/debug.js new file mode 100644 index 000000000..5465b8042 --- /dev/null +++ b/bin/lib/debug.js @@ -0,0 +1,4 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +module.exports = require("../../dist/lib/debug"); diff --git a/bin/lib/inference-config.js b/bin/lib/inference-config.js index ac4acbd2d..7e8faf292 100644 --- a/bin/lib/inference-config.js +++ b/bin/lib/inference-config.js @@ -1,131 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +// +// Thin re-export shim — the implementation lives in src/lib/inference-config.ts, +// compiled to dist/lib/inference-config.js. -const INFERENCE_ROUTE_URL = "https://inference.local/v1"; -const DEFAULT_CLOUD_MODEL = "nvidia/nemotron-3-super-120b-a12b"; -const CLOUD_MODEL_OPTIONS = [ - { id: "nvidia/nemotron-3-super-120b-a12b", label: "Nemotron 3 Super 120B" }, - { id: "moonshotai/kimi-k2.5", label: "Kimi K2.5" }, - { id: "z-ai/glm5", label: "GLM-5" }, - { id: "minimaxai/minimax-m2.5", label: "MiniMax M2.5" }, - { id: "qwen/qwen3.5-397b-a17b", label: "Qwen3.5 397B A17B" }, - { id: "openai/gpt-oss-120b", label: "GPT-OSS 120B" }, -]; -const DEFAULT_ROUTE_PROFILE = "inference-local"; -const DEFAULT_ROUTE_CREDENTIAL_ENV = "OPENAI_API_KEY"; -const MANAGED_PROVIDER_ID = "inference"; -const { DEFAULT_OLLAMA_MODEL } = require("./local-inference"); - -function getProviderSelectionConfig(provider, model) { - switch (provider) { - case "nvidia-prod": - case "nvidia-nim": - return { - endpointType: "custom", - endpointUrl: INFERENCE_ROUTE_URL, - ncpPartner: null, - model: model || DEFAULT_CLOUD_MODEL, - profile: DEFAULT_ROUTE_PROFILE, - credentialEnv: DEFAULT_ROUTE_CREDENTIAL_ENV, - provider, - providerLabel: "NVIDIA Endpoints", - }; - case "openai-api": - return { - endpointType: "custom", - endpointUrl: INFERENCE_ROUTE_URL, - ncpPartner: null, - model: model || "gpt-5.4", - profile: DEFAULT_ROUTE_PROFILE, - credentialEnv: "OPENAI_API_KEY", - provider, - providerLabel: "OpenAI", - }; - case "anthropic-prod": - return { - endpointType: "custom", - endpointUrl: INFERENCE_ROUTE_URL, - ncpPartner: null, - model: model || "claude-sonnet-4-6", - profile: DEFAULT_ROUTE_PROFILE, - credentialEnv: "ANTHROPIC_API_KEY", - provider, - providerLabel: "Anthropic", - }; - case "compatible-anthropic-endpoint": - return { - endpointType: "custom", - endpointUrl: INFERENCE_ROUTE_URL, - ncpPartner: null, - model: model || "custom-anthropic-model", - profile: DEFAULT_ROUTE_PROFILE, - credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY", - provider, - providerLabel: "Other Anthropic-compatible endpoint", - }; - case "gemini-api": - return { - endpointType: "custom", - endpointUrl: INFERENCE_ROUTE_URL, - ncpPartner: null, - model: model || "gemini-2.5-flash", - profile: DEFAULT_ROUTE_PROFILE, - credentialEnv: "GEMINI_API_KEY", - provider, - providerLabel: "Google Gemini", - }; - case "compatible-endpoint": - return { - endpointType: "custom", - endpointUrl: INFERENCE_ROUTE_URL, - ncpPartner: null, - model: model || "custom-model", - profile: DEFAULT_ROUTE_PROFILE, - credentialEnv: "COMPATIBLE_API_KEY", - provider, - providerLabel: "Other OpenAI-compatible endpoint", - }; - case "vllm-local": - return { - endpointType: "custom", - endpointUrl: INFERENCE_ROUTE_URL, - ncpPartner: null, - model: model || "vllm-local", - profile: DEFAULT_ROUTE_PROFILE, - credentialEnv: DEFAULT_ROUTE_CREDENTIAL_ENV, - provider, - providerLabel: "Local vLLM", - }; - case "ollama-local": - return { - endpointType: "custom", - endpointUrl: INFERENCE_ROUTE_URL, - ncpPartner: null, - model: model || DEFAULT_OLLAMA_MODEL, - profile: DEFAULT_ROUTE_PROFILE, - credentialEnv: DEFAULT_ROUTE_CREDENTIAL_ENV, - provider, - providerLabel: "Local Ollama", - }; - default: - return null; - } -} - -function getOpenClawPrimaryModel(provider, model) { - const resolvedModel = - model || (provider === "ollama-local" ? DEFAULT_OLLAMA_MODEL : DEFAULT_CLOUD_MODEL); - return resolvedModel ? `${MANAGED_PROVIDER_ID}/${resolvedModel}` : null; -} - -module.exports = { - CLOUD_MODEL_OPTIONS, - DEFAULT_CLOUD_MODEL, - DEFAULT_OLLAMA_MODEL, - DEFAULT_ROUTE_CREDENTIAL_ENV, - DEFAULT_ROUTE_PROFILE, - INFERENCE_ROUTE_URL, - MANAGED_PROVIDER_ID, - getOpenClawPrimaryModel, - getProviderSelectionConfig, -}; +module.exports = require("../../dist/lib/inference-config"); diff --git a/bin/lib/local-inference.js b/bin/lib/local-inference.js index 3452e59e3..2aa200153 100644 --- a/bin/lib/local-inference.js +++ b/bin/lib/local-inference.js @@ -1,220 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +// +// Thin re-export shim — the implementation lives in src/lib/local-inference.ts, +// compiled to dist/lib/local-inference.js. -const { shellQuote } = require("./runner"); - -const HOST_GATEWAY_URL = "http://host.openshell.internal"; -const CONTAINER_REACHABILITY_IMAGE = "curlimages/curl:8.10.1"; -const DEFAULT_OLLAMA_MODEL = "nemotron-3-nano:30b"; -const SMALL_OLLAMA_MODEL = "qwen2.5:7b"; -const LARGE_OLLAMA_MIN_MEMORY_MB = 32768; - -function getLocalProviderBaseUrl(provider) { - switch (provider) { - case "vllm-local": - return `${HOST_GATEWAY_URL}:8000/v1`; - case "ollama-local": - return `${HOST_GATEWAY_URL}:11434/v1`; - default: - return null; - } -} - -function getLocalProviderValidationBaseUrl(provider) { - switch (provider) { - case "vllm-local": - return "http://localhost:8000/v1"; - case "ollama-local": - return "http://localhost:11434/v1"; - default: - return null; - } -} - -function getLocalProviderHealthCheck(provider) { - switch (provider) { - case "vllm-local": - return "curl -sf http://localhost:8000/v1/models 2>/dev/null"; - case "ollama-local": - return "curl -sf http://localhost:11434/api/tags 2>/dev/null"; - default: - return null; - } -} - -function getLocalProviderContainerReachabilityCheck(provider) { - switch (provider) { - case "vllm-local": - return `docker run --rm --add-host host.openshell.internal:host-gateway ${CONTAINER_REACHABILITY_IMAGE} -sf http://host.openshell.internal:8000/v1/models 2>/dev/null`; - case "ollama-local": - return `docker run --rm --add-host host.openshell.internal:host-gateway ${CONTAINER_REACHABILITY_IMAGE} -sf http://host.openshell.internal:11434/api/tags 2>/dev/null`; - default: - return null; - } -} - -function validateLocalProvider(provider, runCapture) { - const command = getLocalProviderHealthCheck(provider); - if (!command) { - return { ok: true }; - } - - const output = runCapture(command, { ignoreError: true }); - if (!output) { - switch (provider) { - case "vllm-local": - return { - ok: false, - message: "Local vLLM was selected, but nothing is responding on http://localhost:8000.", - }; - case "ollama-local": - return { - ok: false, - message: "Local Ollama was selected, but nothing is responding on http://localhost:11434.", - }; - default: - return { ok: false, message: "The selected local inference provider is unavailable." }; - } - } - - const containerCommand = getLocalProviderContainerReachabilityCheck(provider); - if (!containerCommand) { - return { ok: true }; - } - - const containerOutput = runCapture(containerCommand, { ignoreError: true }); - if (containerOutput) { - return { ok: true }; - } - - switch (provider) { - case "vllm-local": - return { - ok: false, - message: - "Local vLLM is responding on localhost, but containers cannot reach http://host.openshell.internal:8000. Ensure the server is reachable from containers, not only from the host shell.", - }; - case "ollama-local": - return { - ok: false, - message: - "Local Ollama is responding on localhost, but containers cannot reach http://host.openshell.internal:11434. Ensure Ollama listens on 0.0.0.0:11434 instead of 127.0.0.1 so sandboxes can reach it.", - }; - default: - return { ok: false, message: "The selected local inference provider is unavailable from containers." }; - } -} - -function parseOllamaList(output) { - return String(output || "") - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) - .filter((line) => !/^NAME\s+/i.test(line)) - .map((line) => line.split(/\s{2,}/)[0]) - .filter(Boolean); -} - -function parseOllamaTags(output) { - try { - const parsed = JSON.parse(String(output || "")); - return Array.isArray(parsed?.models) - ? parsed.models.map((model) => model && model.name).filter(Boolean) - : []; - } catch { - return []; - } -} - -function getOllamaModelOptions(runCapture) { - const tagsOutput = runCapture("curl -sf http://localhost:11434/api/tags 2>/dev/null", { ignoreError: true }); - const tagsParsed = parseOllamaTags(tagsOutput); - if (tagsParsed.length > 0) { - return tagsParsed; - } - - const listOutput = runCapture("ollama list 2>/dev/null", { ignoreError: true }); - return parseOllamaList(listOutput); -} - -function getBootstrapOllamaModelOptions(gpu) { - const options = [SMALL_OLLAMA_MODEL]; - if (gpu && gpu.totalMemoryMB >= LARGE_OLLAMA_MIN_MEMORY_MB) { - options.push(DEFAULT_OLLAMA_MODEL); - } - return options; -} - -function getDefaultOllamaModel(runCapture, gpu = null) { - const models = getOllamaModelOptions(runCapture); - if (models.length === 0) { - const bootstrap = getBootstrapOllamaModelOptions(gpu); - return bootstrap[0]; - } - return models.includes(DEFAULT_OLLAMA_MODEL) ? DEFAULT_OLLAMA_MODEL : models[0]; -} - -function getOllamaWarmupCommand(model, keepAlive = "15m") { - const payload = JSON.stringify({ - model, - prompt: "hello", - stream: false, - keep_alive: keepAlive, - }); - return `nohup curl -s http://localhost:11434/api/generate -H 'Content-Type: application/json' -d ${shellQuote(payload)} >/dev/null 2>&1 &`; -} - -function getOllamaProbeCommand(model, timeoutSeconds = 120, keepAlive = "15m") { - const payload = JSON.stringify({ - model, - prompt: "hello", - stream: false, - keep_alive: keepAlive, - }); - return `curl -sS --max-time ${timeoutSeconds} http://localhost:11434/api/generate -H 'Content-Type: application/json' -d ${shellQuote(payload)} 2>/dev/null`; -} - -function validateOllamaModel(model, runCapture) { - const output = runCapture(getOllamaProbeCommand(model), { ignoreError: true }); - if (!output) { - return { - ok: false, - message: - `Selected Ollama model '${model}' did not answer the local probe in time. ` + - "It may still be loading, too large for the host, or otherwise unhealthy.", - }; - } - - try { - const parsed = JSON.parse(output); - if (parsed && typeof parsed.error === "string" && parsed.error.trim()) { - return { - ok: false, - message: `Selected Ollama model '${model}' failed the local probe: ${parsed.error.trim()}`, - }; - } - } catch { /* ignored */ } - - return { ok: true }; -} - -module.exports = { - CONTAINER_REACHABILITY_IMAGE, - DEFAULT_OLLAMA_MODEL, - HOST_GATEWAY_URL, - LARGE_OLLAMA_MIN_MEMORY_MB, - SMALL_OLLAMA_MODEL, - getDefaultOllamaModel, - getBootstrapOllamaModelOptions, - getLocalProviderBaseUrl, - getLocalProviderValidationBaseUrl, - getLocalProviderContainerReachabilityCheck, - getLocalProviderHealthCheck, - getOllamaModelOptions, - parseOllamaTags, - getOllamaProbeCommand, - getOllamaWarmupCommand, - parseOllamaList, - validateOllamaModel, - validateLocalProvider, -}; +module.exports = require("../../dist/lib/local-inference"); diff --git a/bin/lib/nim.js b/bin/lib/nim.js index f291a0967..8da775701 100644 --- a/bin/lib/nim.js +++ b/bin/lib/nim.js @@ -1,225 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 // -// NIM container management — pull, start, stop, health-check NIM images. +// Thin re-export shim — the implementation lives in src/lib/nim.ts, +// compiled to dist/lib/nim.js. -const { run, runCapture, shellQuote } = require("./runner"); -const nimImages = require("./nim-images.json"); - -function containerName(sandboxName) { - return `nemoclaw-nim-${sandboxName}`; -} - -function getImageForModel(modelName) { - const entry = nimImages.models.find((m) => m.name === modelName); - return entry ? entry.image : null; -} - -function listModels() { - return nimImages.models.map((m) => ({ - name: m.name, - image: m.image, - minGpuMemoryMB: m.minGpuMemoryMB, - })); -} - -function detectGpu() { - // Try NVIDIA first — query VRAM - try { - const output = runCapture( - "nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits", - { ignoreError: true } - ); - if (output) { - const lines = output.split("\n").filter((l) => l.trim()); - const perGpuMB = lines.map((l) => parseInt(l.trim(), 10)).filter((n) => !isNaN(n)); - if (perGpuMB.length > 0) { - const totalMemoryMB = perGpuMB.reduce((a, b) => a + b, 0); - return { - type: "nvidia", - count: perGpuMB.length, - totalMemoryMB, - perGpuMB: perGpuMB[0], - nimCapable: true, - }; - } - } - } catch { /* ignored */ } - - // Fallback: DGX Spark (GB10) — VRAM not queryable due to unified memory architecture - try { - const nameOutput = runCapture( - "nvidia-smi --query-gpu=name --format=csv,noheader,nounits", - { ignoreError: true } - ); - if (nameOutput && nameOutput.includes("GB10")) { - // GB10 has 128GB unified memory shared with Grace CPU — use system RAM - let totalMemoryMB = 0; - try { - const memLine = runCapture("free -m | awk '/Mem:/ {print $2}'", { ignoreError: true }); - if (memLine) totalMemoryMB = parseInt(memLine.trim(), 10) || 0; - } catch { /* ignored */ } - return { - type: "nvidia", - count: 1, - totalMemoryMB, - perGpuMB: totalMemoryMB, - nimCapable: true, - spark: true, - }; - } - } catch { /* ignored */ } - - // macOS: detect Apple Silicon or discrete GPU - if (process.platform === "darwin") { - try { - const spOutput = runCapture( - "system_profiler SPDisplaysDataType 2>/dev/null", - { ignoreError: true } - ); - if (spOutput) { - const chipMatch = spOutput.match(/Chipset Model:\s*(.+)/); - const vramMatch = spOutput.match(/VRAM.*?:\s*(\d+)\s*(MB|GB)/i); - const coresMatch = spOutput.match(/Total Number of Cores:\s*(\d+)/); - - if (chipMatch) { - const name = chipMatch[1].trim(); - let memoryMB = 0; - - if (vramMatch) { - memoryMB = parseInt(vramMatch[1], 10); - if (vramMatch[2].toUpperCase() === "GB") memoryMB *= 1024; - } else { - // Apple Silicon shares system RAM — read total memory - try { - const memBytes = runCapture("sysctl -n hw.memsize", { ignoreError: true }); - if (memBytes) memoryMB = Math.floor(parseInt(memBytes, 10) / 1024 / 1024); - } catch { /* ignored */ } - } - - return { - type: "apple", - name, - count: 1, - cores: coresMatch ? parseInt(coresMatch[1], 10) : null, - totalMemoryMB: memoryMB, - perGpuMB: memoryMB, - nimCapable: false, - }; - } - } - } catch { /* ignored */ } - } - - return null; -} - -function pullNimImage(model) { - const image = getImageForModel(model); - if (!image) { - console.error(` Unknown model: ${model}`); - process.exit(1); - } - console.log(` Pulling NIM image: ${image}`); - run(`docker pull ${shellQuote(image)}`); - return image; -} - -function startNimContainer(sandboxName, model, port = 8000) { - const name = containerName(sandboxName); - return startNimContainerByName(name, model, port); -} - -function startNimContainerByName(name, model, port = 8000) { - const image = getImageForModel(model); - if (!image) { - console.error(` Unknown model: ${model}`); - process.exit(1); - } - - // Stop any existing container with same name - const qn = shellQuote(name); - run(`docker rm -f ${qn} 2>/dev/null || true`, { ignoreError: true }); - - console.log(` Starting NIM container: ${name}`); - run( - `docker run -d --gpus all -p ${Number(port)}:8000 --name ${qn} --shm-size 16g ${shellQuote(image)}` - ); - return name; -} - -function waitForNimHealth(port = 8000, timeout = 300) { - const start = Date.now(); - const _interval = 5000; - const safePort = Number(port); - console.log(` Waiting for NIM health on port ${safePort} (timeout: ${timeout}s)...`); - - while ((Date.now() - start) / 1000 < timeout) { - try { - const result = runCapture(`curl -sf http://localhost:${safePort}/v1/models`, { - ignoreError: true, - }); - if (result) { - console.log(" NIM is healthy."); - return true; - } - } catch { /* ignored */ } - // Synchronous sleep via spawnSync - require("child_process").spawnSync("sleep", ["5"]); - } - console.error(` NIM did not become healthy within ${timeout}s.`); - return false; -} - -function stopNimContainer(sandboxName) { - const name = containerName(sandboxName); - stopNimContainerByName(name); -} - -function stopNimContainerByName(name) { - const qn = shellQuote(name); - console.log(` Stopping NIM container: ${name}`); - run(`docker stop ${qn} 2>/dev/null || true`, { ignoreError: true }); - run(`docker rm ${qn} 2>/dev/null || true`, { ignoreError: true }); -} - -function nimStatus(sandboxName) { - const name = containerName(sandboxName); - return nimStatusByName(name); -} - -function nimStatusByName(name) { - try { - const state = runCapture( - `docker inspect --format '{{.State.Status}}' ${shellQuote(name)} 2>/dev/null`, - { ignoreError: true } - ); - if (!state) return { running: false, container: name }; - - let healthy = false; - if (state === "running") { - const health = runCapture(`curl -sf http://localhost:8000/v1/models 2>/dev/null`, { - ignoreError: true, - }); - healthy = !!health; - } - return { running: state === "running", healthy, container: name, state }; - } catch { - return { running: false, container: name }; - } -} - -module.exports = { - containerName, - getImageForModel, - listModels, - detectGpu, - pullNimImage, - startNimContainer, - startNimContainerByName, - waitForNimHealth, - stopNimContainer, - stopNimContainerByName, - nimStatus, - nimStatusByName, -}; +module.exports = require("../../dist/lib/nim"); diff --git a/bin/lib/onboard-session.js b/bin/lib/onboard-session.js new file mode 100644 index 000000000..989a1b09d --- /dev/null +++ b/bin/lib/onboard-session.js @@ -0,0 +1,7 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Thin re-export shim — the implementation lives in src/lib/onboard-session.ts, +// compiled to dist/lib/onboard-session.js. + +module.exports = require("../../dist/lib/onboard-session"); diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 48a4cb241..eb9cf20e9 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -9,6 +9,15 @@ const fs = require("fs"); const os = require("os"); const path = require("path"); const { spawn, spawnSync } = require("child_process"); +const pRetry = require("p-retry"); + +/** Parse a numeric env var, returning `fallback` when unset or non-finite. */ +function envInt(name, fallback) { + const raw = process.env[name]; + if (raw === undefined || raw === "") return fallback; + const n = Number(raw); + return Number.isFinite(n) ? Math.max(0, Math.round(n)) : fallback; +} const { ROOT, SCRIPTS, run, runCapture, shellQuote } = require("./runner"); const { getDefaultOllamaModel, @@ -24,24 +33,66 @@ const { CLOUD_MODEL_OPTIONS, DEFAULT_CLOUD_MODEL, getProviderSelectionConfig, + parseGatewayInference, } = require("./inference-config"); const { inferContainerRuntime, isUnsupportedMacosRuntime, + isWsl, shouldPatchCoredns, } = require("./platform"); const { resolveOpenshell } = require("./resolve-openshell"); -const { prompt, ensureApiKey, getCredential, saveCredential } = require("./credentials"); +const { + prompt, + ensureApiKey, + getCredential, + normalizeCredentialValue, + saveCredential, +} = require("./credentials"); const registry = require("./registry"); const nim = require("./nim"); +const onboardSession = require("./onboard-session"); const policies = require("./policies"); -const { checkPortAvailable } = require("./preflight"); +const { ensureUsageNoticeConsent } = require("./usage-notice"); +const { checkPortAvailable, ensureSwap, getMemoryInfo } = require("./preflight"); + +// Typed modules (compiled from src/lib/*.ts → dist/lib/*.js) +const gatewayState = require("../../dist/lib/gateway-state"); +const validation = require("../../dist/lib/validation"); +const urlUtils = require("../../dist/lib/url-utils"); +const buildContext = require("../../dist/lib/build-context"); +const dashboard = require("../../dist/lib/dashboard"); + +/** + * Create a temp file inside a directory with a cryptographically random name. + * Uses fs.mkdtempSync (OS-level mkdtemp) to avoid predictable filenames that + * could be exploited via symlink attacks on shared /tmp. + * Ref: https://github.com/NVIDIA/NemoClaw/issues/1093 + */ +function secureTempFile(prefix, ext = "") { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}-`)); + return path.join(dir, `${prefix}${ext}`); +} + +/** + * Safely remove a mkdtemp-created directory. Guards against accidentally + * deleting the system temp root if a caller passes os.tmpdir() itself. + */ +function cleanupTempDir(filePath, expectedPrefix) { + const parentDir = path.dirname(filePath); + if (parentDir !== os.tmpdir() && path.basename(parentDir).startsWith(`${expectedPrefix}-`)) { + fs.rmSync(parentDir, { recursive: true, force: true }); + } +} + const EXPERIMENTAL = process.env.NEMOCLAW_EXPERIMENTAL === "1"; const USE_COLOR = !process.env.NO_COLOR && !!process.stdout.isTTY; const DIM = USE_COLOR ? "\x1b[2m" : ""; const RESET = USE_COLOR ? "\x1b[0m" : ""; let OPENSHELL_BIN = null; const GATEWAY_NAME = "nemoclaw"; +const BACK_TO_SELECTION = "__NEMOCLAW_BACK_TO_SELECTION__"; +const OPENCLAW_LAUNCH_AGENT_PLIST = "~/Library/LaunchAgents/ai.openclaw.gateway.plist"; const BUILD_ENDPOINT_URL = "https://integrate.api.nvidia.com/v1"; const OPENAI_ENDPOINT_URL = "https://api.openai.com/v1"; @@ -58,6 +109,7 @@ const REMOTE_PROVIDER_CONFIG = { helpUrl: "https://build.nvidia.com/settings/api-keys", modelMode: "catalog", defaultModel: DEFAULT_CLOUD_MODEL, + skipVerify: true, }, openai: { label: "OpenAI", @@ -115,17 +167,8 @@ const REMOTE_PROVIDER_CONFIG = { }; const REMOTE_MODEL_OPTIONS = { - openai: [ - "gpt-5.4", - "gpt-5.4-mini", - "gpt-5.4-nano", - "gpt-5.4-pro-2026-03-05", - ], - anthropic: [ - "claude-sonnet-4-6", - "claude-haiku-4-5", - "claude-opus-4-6", - ], + openai: ["gpt-5.4", "gpt-5.4-mini", "gpt-5.4-nano", "gpt-5.4-pro-2026-03-05"], + anthropic: ["claude-sonnet-4-6", "claude-haiku-4-5", "claude-opus-4-6"], gemini: [ "gemini-3.1-pro-preview", "gemini-3.1-flash-lite-preview", @@ -162,27 +205,29 @@ async function promptOrDefault(question, envVar, defaultValue) { // ── Helpers ────────────────────────────────────────────────────── -/** - * Check if a sandbox is in Ready state from `openshell sandbox list` output. - * Strips ANSI codes and exact-matches the sandbox name in the first column. - */ -function isSandboxReady(output, sandboxName) { - // eslint-disable-next-line no-control-regex - const clean = output.replace(/\x1b\[[0-9;]*m/g, ""); - return clean.split("\n").some((l) => { - const cols = l.trim().split(/\s+/); - return cols[0] === sandboxName && cols.includes("Ready") && !cols.includes("NotReady"); - }); -} - -/** - * Determine whether stale NemoClaw gateway output indicates a previous - * session that should be cleaned up before the port preflight check. - * @param {string} gwInfoOutput - Raw output from `openshell gateway info -g nemoclaw`. - * @returns {boolean} - */ -function hasStaleGateway(gwInfoOutput) { - return typeof gwInfoOutput === "string" && gwInfoOutput.length > 0 && gwInfoOutput.includes(GATEWAY_NAME); +// Gateway state functions — delegated to src/lib/gateway-state.ts +const { + isSandboxReady, + hasStaleGateway, + isSelectedGateway, + isGatewayHealthy, + getGatewayReuseState, + getSandboxStateFromOutputs, +} = gatewayState; + +function getSandboxReuseState(sandboxName) { + if (!sandboxName) return "missing"; + const getOutput = runCaptureOpenshell(["sandbox", "get", sandboxName], { ignoreError: true }); + const listOutput = runCaptureOpenshell(["sandbox", "list"], { ignoreError: true }); + return getSandboxStateFromOutputs(sandboxName, getOutput, listOutput); +} + +function repairRecordedSandbox(sandboxName) { + if (!sandboxName) return; + note(` [resume] Cleaning up recorded sandbox '${sandboxName}' before recreating it.`); + runOpenshell(["forward", "stop", "18789"], { ignoreError: true }); + runOpenshell(["sandbox", "delete", sandboxName], { ignoreError: true }); + registry.removeSandbox(sandboxName); } function streamSandboxCreate(command, env = process.env, options = {}) { @@ -199,12 +244,45 @@ function streamSandboxCreate(command, env = process.env, options = {}) { let settled = false; let polling = false; const pollIntervalMs = options.pollIntervalMs || 2000; + const heartbeatIntervalMs = options.heartbeatIntervalMs || 5000; + const silentPhaseMs = options.silentPhaseMs || 15000; + const startedAt = Date.now(); + let lastOutputAt = startedAt; + let currentPhase = "build"; + let lastHeartbeatPhase = null; + let lastHeartbeatBucket = -1; + + function elapsedSeconds() { + return Math.max(0, Math.floor((Date.now() - startedAt) / 1000)); + } + + function setPhase(nextPhase) { + if (!nextPhase || nextPhase === currentPhase) return; + currentPhase = nextPhase; + lastHeartbeatPhase = null; + lastHeartbeatBucket = -1; + const phaseLine = + nextPhase === "build" + ? " Building sandbox image..." + : nextPhase === "upload" + ? " Uploading image into OpenShell gateway..." + : nextPhase === "create" + ? " Creating sandbox in gateway..." + : nextPhase === "ready" + ? " Waiting for sandbox to become ready..." + : null; + if (phaseLine && phaseLine !== lastPrintedLine) { + console.log(phaseLine); + lastPrintedLine = phaseLine; + } + } function finish(result) { if (settled) return; settled = true; if (pending) flushLine(pending); if (readyTimer) clearInterval(readyTimer); + if (heartbeatTimer) clearInterval(heartbeatTimer); resolvePromise(result); } @@ -221,6 +299,7 @@ function streamSandboxCreate(command, env = process.env, options = {}) { function shouldShowLine(line) { return ( /^ {2}Building image /.test(line) || + /^ {2}Step \d+\/\d+ : /.test(line) || /^ {2}Context: /.test(line) || /^ {2}Gateway: /.test(line) || /^Successfully built /.test(line) || @@ -238,6 +317,18 @@ function streamSandboxCreate(command, env = process.env, options = {}) { const line = rawLine.replace(/\r/g, "").trimEnd(); if (!line) return; lines.push(line); + lastOutputAt = Date.now(); + if (/^ {2}Building image /.test(line) || /^ {2}Step \d+\/\d+ : /.test(line)) { + setPhase("build"); + } else if ( + /^ {2}Pushing image /.test(line) || + /^\s*\[progress\]/.test(line) || + /^ {2}Image .*available in the gateway/.test(line) + ) { + setPhase("upload"); + } else if (/^Created sandbox: /.test(line)) { + setPhase("create"); + } if (shouldShowLine(line) && line !== lastPrintedLine) { console.log(line); lastPrintedLine = line; @@ -268,6 +359,7 @@ function streamSandboxCreate(command, env = process.env, options = {}) { return; } if (!ready) return; + setPhase("ready"); const detail = "Sandbox reported Ready before create stream exited; continuing."; lines.push(detail); if (detail !== lastPrintedLine) { @@ -288,6 +380,33 @@ function streamSandboxCreate(command, env = process.env, options = {}) { : null; readyTimer?.unref?.(); + setPhase("build"); + const heartbeatTimer = setInterval(() => { + if (settled) return; + const silentForMs = Date.now() - lastOutputAt; + if (silentForMs < silentPhaseMs) return; + const elapsed = elapsedSeconds(); + const bucket = Math.floor(elapsed / 15); + if (currentPhase === lastHeartbeatPhase && bucket === lastHeartbeatBucket) { + return; + } + const heartbeatLine = + currentPhase === "upload" + ? ` Still uploading image into OpenShell gateway... (${elapsed}s elapsed)` + : currentPhase === "create" + ? ` Still creating sandbox in gateway... (${elapsed}s elapsed)` + : currentPhase === "ready" + ? ` Still waiting for sandbox to become ready... (${elapsed}s elapsed)` + : ` Still building sandbox image... (${elapsed}s elapsed)`; + if (heartbeatLine !== lastPrintedLine) { + console.log(heartbeatLine); + lastPrintedLine = heartbeatLine; + lastHeartbeatPhase = currentPhase; + lastHeartbeatBucket = bucket; + } + }, heartbeatIntervalMs); + heartbeatTimer.unref?.(); + return new Promise((resolve) => { resolvePromise = resolve; child.on("error", (error) => { @@ -349,12 +468,274 @@ function runCaptureOpenshell(args, opts = {}) { return runCapture(openshellShellCommand(args), opts); } -function formatEnvAssignment(name, value) { - return `${name}=${value}`; +// URL/string utilities — delegated to src/lib/url-utils.ts +const { + compactText, + normalizeProviderBaseUrl, + isLoopbackHostname, + formatEnvAssignment, + parsePolicyPresetEnv, +} = urlUtils; + +function hydrateCredentialEnv(envName) { + if (!envName) return null; + const value = getCredential(envName); + if (value) { + process.env[envName] = value; + } + return value || null; } function getCurlTimingArgs() { - return ["--connect-timeout 5", "--max-time 20"]; + return ["--connect-timeout", "10", "--max-time", "60", "--http1.1"]; +} + +function summarizeCurlFailure(curlStatus = 0, stderr = "", body = "") { + const detail = compactText(stderr || body); + return detail + ? `curl failed (exit ${curlStatus}): ${detail.slice(0, 200)}` + : `curl failed (exit ${curlStatus})`; +} + +function summarizeProbeFailure(body = "", status = 0, curlStatus = 0, stderr = "") { + if (curlStatus) { + return summarizeCurlFailure(curlStatus, stderr, body); + } + return summarizeProbeError(body, status); +} + +function getNavigationChoice(value = "") { + const normalized = String(value || "") + .trim() + .toLowerCase(); + if (normalized === "back") return "back"; + if (normalized === "exit" || normalized === "quit") return "exit"; + return null; +} + +function exitOnboardFromPrompt() { + console.log(" Exiting onboarding."); + process.exit(1); +} + +function getTransportRecoveryMessage(failure = {}) { + const text = compactText(`${failure.message || ""} ${failure.stderr || ""}`).toLowerCase(); + if (failure.curlStatus === 2 || /option .* is unknown|curl --help|curl --manual/.test(text)) { + return " Validation hit a local curl invocation error. Retry after updating NemoClaw or use a different provider temporarily."; + } + if (failure.httpStatus === 429) { + return " The provider is rate limiting validation requests right now."; + } + if (failure.httpStatus >= 500 && failure.httpStatus < 600) { + return " The provider endpoint is reachable but currently failing upstream."; + } + if (failure.curlStatus === 6 || /could not resolve host|name or service not known/.test(text)) { + return " Validation could not resolve the provider hostname. Check DNS, VPN, or the endpoint URL."; + } + if (failure.curlStatus === 7 || /connection refused|failed to connect/.test(text)) { + return " Validation could not connect to the provider endpoint. Check the URL, proxy, or that the service is up."; + } + if (failure.curlStatus === 28 || /timed out|timeout/.test(text)) { + return " Validation timed out before the provider replied. Retry, or check network/proxy health."; + } + if (failure.curlStatus === 35 || failure.curlStatus === 60 || /ssl|tls|certificate/.test(text)) { + return " Validation hit a TLS/certificate error. Check HTTPS trust and whether the endpoint URL is correct."; + } + if (/proxy/.test(text)) { + return " Validation hit a proxy/connectivity error. Check proxy environment settings and endpoint reachability."; + } + return " Validation hit a network or transport error."; +} + +// Validation functions — delegated to src/lib/validation.ts +const { + classifyValidationFailure, + classifyApplyFailure, + classifySandboxCreateFailure, + validateNvidiaApiKeyValue, + isSafeModelId, +} = validation; + +function getProbeRecovery(probe, options = {}) { + const allowModelRetry = options.allowModelRetry === true; + const failures = Array.isArray(probe?.failures) ? probe.failures : []; + if (failures.length === 0) { + return { kind: "unknown", retry: "selection" }; + } + if (failures.some((failure) => classifyValidationFailure(failure).kind === "credential")) { + return { kind: "credential", retry: "credential" }; + } + const transportFailure = failures.find( + (failure) => classifyValidationFailure(failure).kind === "transport", + ); + if (transportFailure) { + return { kind: "transport", retry: "retry", failure: transportFailure }; + } + if ( + allowModelRetry && + failures.some((failure) => classifyValidationFailure(failure).kind === "model") + ) { + return { kind: "model", retry: "model" }; + } + if (failures.some((failure) => classifyValidationFailure(failure).kind === "endpoint")) { + return { kind: "endpoint", retry: "selection" }; + } + const fallback = classifyValidationFailure(failures[0]); + if (!allowModelRetry && fallback.kind === "model") { + return { kind: "unknown", retry: "selection" }; + } + return fallback; +} + +// eslint-disable-next-line complexity +function runCurlProbe(argv) { + const bodyFile = secureTempFile("nemoclaw-curl-probe", ".json"); + try { + const args = [...argv]; + const url = args.pop(); + const result = spawnSync("curl", [...args, "-o", bodyFile, "-w", "%{http_code}", url], { + cwd: ROOT, + encoding: "utf8", + timeout: 30_000, + env: { + ...process.env, + }, + }); + const body = fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, "utf8") : ""; + if (result.error) { + const spawnError = /** @type {NodeJS.ErrnoException} */ (result.error); + const rawErrorCode = spawnError.errno ?? spawnError.code; + const errorCode = typeof rawErrorCode === "number" ? rawErrorCode : 1; + const errorMessage = compactText( + `${spawnError.message || String(spawnError)} ${String(result.stderr || "")}`, + ); + return { + ok: false, + httpStatus: 0, + curlStatus: errorCode, + body, + stderr: errorMessage, + message: summarizeProbeFailure(body, 0, errorCode, errorMessage), + }; + } + const status = Number(String(result.stdout || "").trim()); + return { + ok: result.status === 0 && status >= 200 && status < 300, + httpStatus: Number.isFinite(status) ? status : 0, + curlStatus: result.status || 0, + body, + stderr: String(result.stderr || ""), + message: summarizeProbeFailure( + body, + status || 0, + result.status || 0, + String(result.stderr || ""), + ), + }; + } catch (error) { + return { + ok: false, + httpStatus: 0, + curlStatus: error?.status || 1, + body: "", + stderr: error?.message || String(error), + message: summarizeCurlFailure(error?.status || 1, error?.message || String(error)), + }; + } finally { + cleanupTempDir(bodyFile, "nemoclaw-curl-probe"); + } +} + +// validateNvidiaApiKeyValue — see validation import above + +async function replaceNamedCredential(envName, label, helpUrl = null, validator = null) { + if (helpUrl) { + console.log(""); + console.log(` Get your ${label} from: ${helpUrl}`); + console.log(""); + } + + while (true) { + const key = normalizeCredentialValue(await prompt(` ${label}: `, { secret: true })); + if (!key) { + console.error(` ${label} is required.`); + continue; + } + const validationError = typeof validator === "function" ? validator(key) : null; + if (validationError) { + console.error(validationError); + continue; + } + saveCredential(envName, key); + process.env[envName] = key; + console.log(""); + console.log(` Key saved to ~/.nemoclaw/credentials.json (mode 600)`); + console.log(""); + return key; + } +} + +async function promptValidationRecovery(label, recovery, credentialEnv = null, helpUrl = null) { + if (isNonInteractive()) { + process.exit(1); + } + + if (recovery.kind === "credential" && credentialEnv) { + console.log( + ` ${label} authorization failed. Re-enter the API key or choose a different provider/model.`, + ); + const choice = (await prompt(" Type 'retry', 'back', or 'exit' [retry]: ")) + .trim() + .toLowerCase(); + if (choice === "back") { + console.log(" Returning to provider selection."); + console.log(""); + return "selection"; + } + if (choice === "exit" || choice === "quit") { + exitOnboardFromPrompt(); + } + if (choice === "" || choice === "retry") { + const validator = credentialEnv === "NVIDIA_API_KEY" ? validateNvidiaApiKeyValue : null; + await replaceNamedCredential(credentialEnv, `${label} API key`, helpUrl, validator); + return "credential"; + } + console.log(" Please choose a provider/model again."); + console.log(""); + return "selection"; + } + + if (recovery.kind === "transport") { + console.log(getTransportRecoveryMessage(recovery.failure || {})); + const choice = (await prompt(" Type 'retry', 'back', or 'exit' [retry]: ")) + .trim() + .toLowerCase(); + if (choice === "back") { + console.log(" Returning to provider selection."); + console.log(""); + return "selection"; + } + if (choice === "exit" || choice === "quit") { + exitOnboardFromPrompt(); + } + if (choice === "" || choice === "retry") { + console.log(""); + return "retry"; + } + console.log(" Please choose a provider/model again."); + console.log(""); + return "selection"; + } + + if (recovery.kind === "model") { + console.log(` Please enter a different ${label} model name.`); + console.log(""); + return "model"; + } + + console.log(" Please choose a provider/model again."); + console.log(""); + return "selection"; } function buildProviderArgs(action, name, type, credentialEnv, baseUrl) { @@ -372,15 +753,28 @@ function buildProviderArgs(action, name, type, credentialEnv, baseUrl) { function upsertProvider(name, type, credentialEnv, baseUrl, env = {}) { const createArgs = buildProviderArgs("create", name, type, credentialEnv, baseUrl); - const createResult = runOpenshell(createArgs, { ignoreError: true, env }); - if (createResult.status === 0) return; + const runOpts = { ignoreError: true, env, stdio: ["ignore", "pipe", "pipe"] }; + const createResult = runOpenshell(createArgs, runOpts); + if (createResult.status === 0) { + console.log(`✓ Created provider ${name}`); + return { ok: true }; + } const updateArgs = buildProviderArgs("update", name, type, credentialEnv, baseUrl); - const updateResult = runOpenshell(updateArgs, { ignoreError: true, env }); + const updateResult = runOpenshell(updateArgs, runOpts); if (updateResult.status !== 0) { - console.error(` Failed to create or update provider '${name}'.`); - process.exit(updateResult.status || createResult.status || 1); + const output = + compactText(`${createResult.stderr || ""} ${updateResult.stderr || ""}`) || + compactText(`${createResult.stdout || ""} ${updateResult.stdout || ""}`) || + `Failed to create or update provider '${name}'.`; + return { + ok: false, + status: updateResult.status || createResult.status || 1, + message: output, + }; } + console.log(`✓ Updated provider ${name}`); + return { ok: true }; } function verifyInferenceRoute(_provider, _model) { @@ -391,6 +785,13 @@ function verifyInferenceRoute(_provider, _model) { } } +function isInferenceRouteReady(provider, model) { + const live = parseGatewayInference( + runCaptureOpenshell(["inference", "get"], { ignoreError: true }), + ); + return Boolean(live && live.provider === provider && live.model === model); +} + function sandboxExistsInGateway(sandboxName) { const output = runCaptureOpenshell(["sandbox", "get", sandboxName], { ignoreError: true }); return Boolean(output); @@ -420,8 +821,12 @@ exit `.trim(); } -function writeSandboxConfigSyncFile(script, tmpDir = os.tmpdir(), now = Date.now()) { - const scriptFile = path.join(tmpDir, `nemoclaw-sync-${now}.sh`); +function isOpenclawReady(sandboxName) { + return Boolean(fetchGatewayAuthTokenFromSandbox(sandboxName)); +} + +function writeSandboxConfigSyncFile(script) { + const scriptFile = secureTempFile("nemoclaw-sync", ".sh"); fs.writeFileSync(scriptFile, `${script}\n`, { mode: 0o600 }); return scriptFile; } @@ -474,46 +879,48 @@ function getSandboxInferenceConfig(model, provider = null, preferredInferenceApi return { providerKey, primaryModelRef, inferenceBaseUrl, inferenceApi, inferenceCompat }; } -function patchStagedDockerfile(dockerfilePath, model, chatUiUrl, buildId = String(Date.now()), provider = null, preferredInferenceApi = null) { - const { - providerKey, - primaryModelRef, - inferenceBaseUrl, - inferenceApi, - inferenceCompat, - } = getSandboxInferenceConfig(model, provider, preferredInferenceApi); +function patchStagedDockerfile( + dockerfilePath, + model, + chatUiUrl, + buildId = String(Date.now()), + provider = null, + preferredInferenceApi = null, +) { + const { providerKey, primaryModelRef, inferenceBaseUrl, inferenceApi, inferenceCompat } = + getSandboxInferenceConfig(model, provider, preferredInferenceApi); let dockerfile = fs.readFileSync(dockerfilePath, "utf8"); - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_MODEL=.*$/m, - `ARG NEMOCLAW_MODEL=${model}` - ); + dockerfile = dockerfile.replace(/^ARG NEMOCLAW_MODEL=.*$/m, `ARG NEMOCLAW_MODEL=${model}`); dockerfile = dockerfile.replace( /^ARG NEMOCLAW_PROVIDER_KEY=.*$/m, - `ARG NEMOCLAW_PROVIDER_KEY=${providerKey}` + `ARG NEMOCLAW_PROVIDER_KEY=${providerKey}`, ); dockerfile = dockerfile.replace( /^ARG NEMOCLAW_PRIMARY_MODEL_REF=.*$/m, - `ARG NEMOCLAW_PRIMARY_MODEL_REF=${primaryModelRef}` - ); - dockerfile = dockerfile.replace( - /^ARG CHAT_UI_URL=.*$/m, - `ARG CHAT_UI_URL=${chatUiUrl}` + `ARG NEMOCLAW_PRIMARY_MODEL_REF=${primaryModelRef}`, ); + dockerfile = dockerfile.replace(/^ARG CHAT_UI_URL=.*$/m, `ARG CHAT_UI_URL=${chatUiUrl}`); dockerfile = dockerfile.replace( /^ARG NEMOCLAW_INFERENCE_BASE_URL=.*$/m, - `ARG NEMOCLAW_INFERENCE_BASE_URL=${inferenceBaseUrl}` + `ARG NEMOCLAW_INFERENCE_BASE_URL=${inferenceBaseUrl}`, ); dockerfile = dockerfile.replace( /^ARG NEMOCLAW_INFERENCE_API=.*$/m, - `ARG NEMOCLAW_INFERENCE_API=${inferenceApi}` + `ARG NEMOCLAW_INFERENCE_API=${inferenceApi}`, ); dockerfile = dockerfile.replace( /^ARG NEMOCLAW_INFERENCE_COMPAT_B64=.*$/m, - `ARG NEMOCLAW_INFERENCE_COMPAT_B64=${encodeDockerJsonArg(inferenceCompat)}` + `ARG NEMOCLAW_INFERENCE_COMPAT_B64=${encodeDockerJsonArg(inferenceCompat)}`, ); dockerfile = dockerfile.replace( /^ARG NEMOCLAW_BUILD_ID=.*$/m, - `ARG NEMOCLAW_BUILD_ID=${buildId}` + `ARG NEMOCLAW_BUILD_ID=${buildId}`, + ); + // Onboard flow expects immediate dashboard access without device pairing, + // so disable device auth for images built during onboard (see #1217). + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_DISABLE_DEVICE_AUTH=.*$/m, + `ARG NEMOCLAW_DISABLE_DEVICE_AUTH=1`, ); fs.writeFileSync(dockerfilePath, dockerfile); } @@ -529,7 +936,9 @@ function summarizeProbeError(body, status) { parsed?.detail || parsed?.details; if (message) return `HTTP ${status}: ${String(message)}`; - } catch { /* non-JSON body — fall through to raw text */ } + } catch { + /* non-JSON body — fall through to raw text */ + } const compact = String(body).replace(/\s+/g, " ").trim(); return `HTTP ${status}: ${compact.slice(0, 200)}`; } @@ -551,49 +960,32 @@ function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey) { url: `${String(endpointUrl).replace(/\/+$/, "")}/chat/completions`, body: JSON.stringify({ model, - messages: [ - { role: "user", content: "Reply with exactly: OK" }, - ], + messages: [{ role: "user", content: "Reply with exactly: OK" }], }), }, ]; const failures = []; for (const probe of probes) { - const bodyFile = path.join(os.tmpdir(), `nemoclaw-probe-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); - try { - const cmd = [ - "curl -sS", - ...getCurlTimingArgs(), - `-o ${shellQuote(bodyFile)}`, - "-w '%{http_code}'", - "-H 'Content-Type: application/json'", - ...(apiKey ? ['-H "Authorization: Bearer $NEMOCLAW_PROBE_API_KEY"'] : []), - `-d ${shellQuote(probe.body)}`, - shellQuote(probe.url), - ].join(" "); - const result = spawnSync("bash", ["-c", cmd], { - cwd: ROOT, - encoding: "utf8", - env: { - ...process.env, - NEMOCLAW_PROBE_API_KEY: apiKey, - }, - }); - const body = fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, "utf8") : ""; - const status = Number(String(result.stdout || "").trim()); - if (result.status === 0 && status >= 200 && status < 300) { - return { ok: true, api: probe.api, label: probe.name }; - } - failures.push({ - name: probe.name, - httpStatus: Number.isFinite(status) ? status : 0, - curlStatus: result.status || 0, - message: summarizeProbeError(body, status || result.status || 0), - }); - } finally { - fs.rmSync(bodyFile, { force: true }); + const result = runCurlProbe([ + "-sS", + ...getCurlTimingArgs(), + "-H", + "Content-Type: application/json", + ...(apiKey ? ["-H", `Authorization: Bearer ${normalizeCredentialValue(apiKey)}`] : []), + "-d", + probe.body, + probe.url, + ]); + if (result.ok) { + return { ok: true, api: probe.api, label: probe.name }; } + failures.push({ + name: probe.name, + httpStatus: result.httpStatus, + curlStatus: result.curlStatus, + message: result.message, + }); } return { @@ -604,59 +996,38 @@ function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey) { } function probeAnthropicEndpoint(endpointUrl, model, apiKey) { - const bodyFile = path.join(os.tmpdir(), `nemoclaw-anthropic-probe-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); - try { - const cmd = [ - "curl -sS", - ...getCurlTimingArgs(), - `-o ${shellQuote(bodyFile)}`, - "-w '%{http_code}'", - '-H "x-api-key: $NEMOCLAW_PROBE_API_KEY"', - "-H 'anthropic-version: 2023-06-01'", - "-H 'content-type: application/json'", - `-d ${shellQuote(JSON.stringify({ - model, - max_tokens: 16, - messages: [{ role: "user", content: "Reply with exactly: OK" }], - }))}`, - shellQuote(`${String(endpointUrl).replace(/\/+$/, "")}/v1/messages`), - ].join(" "); - const result = spawnSync("bash", ["-c", cmd], { - cwd: ROOT, - encoding: "utf8", - env: { - ...process.env, - NEMOCLAW_PROBE_API_KEY: apiKey, - }, - }); - const body = fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, "utf8") : ""; - const status = Number(String(result.stdout || "").trim()); - if (result.status === 0 && status >= 200 && status < 300) { - return { ok: true, api: "anthropic-messages", label: "Anthropic Messages API" }; - } - return { - ok: false, - message: summarizeProbeError(body, status || result.status || 0), - failures: [ - { - name: "Anthropic Messages API", - httpStatus: Number.isFinite(status) ? status : 0, - curlStatus: result.status || 0, - }, - ], - }; - } finally { - fs.rmSync(bodyFile, { force: true }); + const result = runCurlProbe([ + "-sS", + ...getCurlTimingArgs(), + "-H", + `x-api-key: ${normalizeCredentialValue(apiKey)}`, + "-H", + "anthropic-version: 2023-06-01", + "-H", + "content-type: application/json", + "-d", + JSON.stringify({ + model, + max_tokens: 16, + messages: [{ role: "user", content: "Reply with exactly: OK" }], + }), + `${String(endpointUrl).replace(/\/+$/, "")}/v1/messages`, + ]); + if (result.ok) { + return { ok: true, api: "anthropic-messages", label: "Anthropic Messages API" }; } -} - -function shouldRetryProviderSelection(probe) { - const failures = Array.isArray(probe?.failures) ? probe.failures : []; - if (failures.length === 0) return true; - return failures.some((failure) => { - if ((failure.curlStatus || 0) !== 0) return true; - return [0, 401, 403, 404].includes(failure.httpStatus || 0); - }); + return { + ok: false, + message: result.message, + failures: [ + { + name: "Anthropic Messages API", + httpStatus: result.httpStatus, + curlStatus: result.curlStatus, + message: result.message, + }, + ], + }; } async function validateOpenAiLikeSelection( @@ -664,7 +1035,8 @@ async function validateOpenAiLikeSelection( endpointUrl, model, credentialEnv = null, - retryMessage = "Please choose a provider/model again." + retryMessage = "Please choose a provider/model again.", + helpUrl = null, ) { const apiKey = credentialEnv ? getCredential(credentialEnv) : ""; const probe = probeOpenAiLikeEndpoint(endpointUrl, model, apiKey); @@ -674,12 +1046,20 @@ async function validateOpenAiLikeSelection( if (isNonInteractive()) { process.exit(1); } - console.log(` ${retryMessage}`); - console.log(""); - return null; + const retry = await promptValidationRecovery( + label, + getProbeRecovery(probe), + credentialEnv, + helpUrl, + ); + if (retry === "selection") { + console.log(` ${retryMessage}`); + console.log(""); + } + return { ok: false, retry }; } console.log(` ${probe.label} available — OpenClaw will use ${probe.api}.`); - return probe.api; + return { ok: true, api: probe.api }; } async function validateAnthropicSelectionWithRetryMessage( @@ -687,7 +1067,8 @@ async function validateAnthropicSelectionWithRetryMessage( endpointUrl, model, credentialEnv, - retryMessage = "Please choose a provider/model again." + retryMessage = "Please choose a provider/model again.", + helpUrl = null, ) { const apiKey = getCredential(credentialEnv); const probe = probeAnthropicEndpoint(endpointUrl, model, apiKey); @@ -697,15 +1078,29 @@ async function validateAnthropicSelectionWithRetryMessage( if (isNonInteractive()) { process.exit(1); } - console.log(` ${retryMessage}`); - console.log(""); - return null; + const retry = await promptValidationRecovery( + label, + getProbeRecovery(probe), + credentialEnv, + helpUrl, + ); + if (retry === "selection") { + console.log(` ${retryMessage}`); + console.log(""); + } + return { ok: false, retry }; } console.log(` ${probe.label} available — OpenClaw will use ${probe.api}.`); - return probe.api; + return { ok: true, api: probe.api }; } -async function validateCustomOpenAiLikeSelection(label, endpointUrl, model, credentialEnv) { +async function validateCustomOpenAiLikeSelection( + label, + endpointUrl, + model, + credentialEnv, + helpUrl = null, +) { const apiKey = getCredential(credentialEnv); const probe = probeOpenAiLikeEndpoint(endpointUrl, model, apiKey); if (probe.ok) { @@ -717,17 +1112,26 @@ async function validateCustomOpenAiLikeSelection(label, endpointUrl, model, cred if (isNonInteractive()) { process.exit(1); } - if (shouldRetryProviderSelection(probe)) { + const retry = await promptValidationRecovery( + label, + getProbeRecovery(probe, { allowModelRetry: true }), + credentialEnv, + helpUrl, + ); + if (retry === "selection") { console.log(" Please choose a provider/model again."); console.log(""); - return { ok: false, retry: "selection" }; } - console.log(` Please enter a different ${label} model name.`); - console.log(""); - return { ok: false, retry: "model" }; + return { ok: false, retry }; } -async function validateCustomAnthropicSelection(label, endpointUrl, model, credentialEnv) { +async function validateCustomAnthropicSelection( + label, + endpointUrl, + model, + credentialEnv, + helpUrl = null, +) { const apiKey = getCredential(credentialEnv); const probe = probeAnthropicEndpoint(endpointUrl, model, apiKey); if (probe.ok) { @@ -739,50 +1143,45 @@ async function validateCustomAnthropicSelection(label, endpointUrl, model, crede if (isNonInteractive()) { process.exit(1); } - if (shouldRetryProviderSelection(probe)) { + const retry = await promptValidationRecovery( + label, + getProbeRecovery(probe, { allowModelRetry: true }), + credentialEnv, + helpUrl, + ); + if (retry === "selection") { console.log(" Please choose a provider/model again."); console.log(""); - return { ok: false, retry: "selection" }; } - console.log(` Please enter a different ${label} model name.`); - console.log(""); - return { ok: false, retry: "model" }; + return { ok: false, retry }; } function fetchNvidiaEndpointModels(apiKey) { - const bodyFile = path.join(os.tmpdir(), `nemoclaw-nvidia-models-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); try { - const cmd = [ - "curl -sS", + const result = runCurlProbe([ + "-sS", ...getCurlTimingArgs(), - `-o ${shellQuote(bodyFile)}`, - "-w '%{http_code}'", - "-H 'Content-Type: application/json'", - '-H "Authorization: Bearer $NEMOCLAW_PROBE_API_KEY"', - shellQuote(`${BUILD_ENDPOINT_URL}/models`), - ].join(" "); - const result = spawnSync("bash", ["-c", cmd], { - cwd: ROOT, - encoding: "utf8", - env: { - ...process.env, - NEMOCLAW_PROBE_API_KEY: apiKey, - }, - }); - const body = fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, "utf8") : ""; - const status = Number(String(result.stdout || "").trim()); - if (result.status !== 0 || !(status >= 200 && status < 300)) { - return { ok: false, message: summarizeProbeError(body, status || result.status || 0) }; + "-H", + "Content-Type: application/json", + "-H", + `Authorization: Bearer ${normalizeCredentialValue(apiKey)}`, + `${BUILD_ENDPOINT_URL}/models`, + ]); + if (!result.ok) { + return { + ok: false, + message: result.message, + status: result.httpStatus, + curlStatus: result.curlStatus, + }; } - const parsed = JSON.parse(body); + const parsed = JSON.parse(result.body); const ids = Array.isArray(parsed?.data) ? parsed.data.map((item) => item && item.id).filter(Boolean) : []; return { ok: true, ids }; } catch (error) { return { ok: false, message: error.message || String(error) }; - } finally { - fs.rmSync(bodyFile, { force: true }); } } @@ -804,75 +1203,57 @@ function validateNvidiaEndpointModel(model, apiKey) { } function fetchOpenAiLikeModels(endpointUrl, apiKey) { - const bodyFile = path.join(os.tmpdir(), `nemoclaw-openai-models-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); try { - const cmd = [ - "curl -sS", + const result = runCurlProbe([ + "-sS", ...getCurlTimingArgs(), - `-o ${shellQuote(bodyFile)}`, - "-w '%{http_code}'", - ...(apiKey ? ['-H "Authorization: Bearer $NEMOCLAW_PROBE_API_KEY"'] : []), - shellQuote(`${String(endpointUrl).replace(/\/+$/, "")}/models`), - ].join(" "); - const result = spawnSync("bash", ["-c", cmd], { - cwd: ROOT, - encoding: "utf8", - env: { - ...process.env, - NEMOCLAW_PROBE_API_KEY: apiKey, - }, - }); - const body = fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, "utf8") : ""; - const status = Number(String(result.stdout || "").trim()); - if (result.status !== 0 || !(status >= 200 && status < 300)) { - return { ok: false, status, message: summarizeProbeError(body, status || result.status || 0) }; + ...(apiKey ? ["-H", `Authorization: Bearer ${normalizeCredentialValue(apiKey)}`] : []), + `${String(endpointUrl).replace(/\/+$/, "")}/models`, + ]); + if (!result.ok) { + return { + ok: false, + status: result.httpStatus, + curlStatus: result.curlStatus, + message: result.message, + }; } - const parsed = JSON.parse(body); + const parsed = JSON.parse(result.body); const ids = Array.isArray(parsed?.data) ? parsed.data.map((item) => item && item.id).filter(Boolean) : []; return { ok: true, ids }; } catch (error) { return { ok: false, status: 0, message: error.message || String(error) }; - } finally { - fs.rmSync(bodyFile, { force: true }); } } function fetchAnthropicModels(endpointUrl, apiKey) { - const bodyFile = path.join(os.tmpdir(), `nemoclaw-anthropic-models-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); try { - const cmd = [ - "curl -sS", + const result = runCurlProbe([ + "-sS", ...getCurlTimingArgs(), - `-o ${shellQuote(bodyFile)}`, - "-w '%{http_code}'", - '-H "x-api-key: $NEMOCLAW_PROBE_API_KEY"', - "-H 'anthropic-version: 2023-06-01'", - shellQuote(`${String(endpointUrl).replace(/\/+$/, "")}/v1/models`), - ].join(" "); - const result = spawnSync("bash", ["-c", cmd], { - cwd: ROOT, - encoding: "utf8", - env: { - ...process.env, - NEMOCLAW_PROBE_API_KEY: apiKey, - }, - }); - const body = fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, "utf8") : ""; - const status = Number(String(result.stdout || "").trim()); - if (result.status !== 0 || !(status >= 200 && status < 300)) { - return { ok: false, status, message: summarizeProbeError(body, status || result.status || 0) }; + "-H", + `x-api-key: ${normalizeCredentialValue(apiKey)}`, + "-H", + "anthropic-version: 2023-06-01", + `${String(endpointUrl).replace(/\/+$/, "")}/v1/models`, + ]); + if (!result.ok) { + return { + ok: false, + status: result.httpStatus, + curlStatus: result.curlStatus, + message: result.message, + }; } - const parsed = JSON.parse(body); + const parsed = JSON.parse(result.body); const ids = Array.isArray(parsed?.data) ? parsed.data.map((item) => item && (item.id || item.name)).filter(Boolean) : []; return { ok: true, ids }; } catch (error) { return { ok: false, status: 0, message: error.message || String(error) }; - } finally { - fs.rmSync(bodyFile, { force: true }); } } @@ -920,6 +1301,13 @@ async function promptManualModelId(promptLabel, errorLabel, validator = null) { while (true) { const manual = await prompt(promptLabel); const trimmed = manual.trim(); + const navigation = getNavigationChoice(trimmed); + if (navigation === "back") { + return BACK_TO_SELECTION; + } + if (navigation === "exit") { + exitOnboardFromPrompt(); + } if (!trimmed || !isSafeModelId(trimmed)) { console.error(` Invalid ${errorLabel} model id.`); continue; @@ -934,6 +1322,10 @@ async function promptManualModelId(promptLabel, errorLabel, validator = null) { return trimmed; } } +// Build context helpers — delegated to src/lib/build-context.ts +const { shouldIncludeBuildContextPath, copyBuildContextDir, printSandboxCreateRecoveryHints } = + buildContext; +// classifySandboxCreateFailure — see validation import above async function promptCloudModel() { console.log(""); @@ -945,15 +1337,20 @@ async function promptCloudModel() { console.log(""); const choice = await prompt(" Choose model [1]: "); + const navigation = getNavigationChoice(choice); + if (navigation === "back") { + return BACK_TO_SELECTION; + } + if (navigation === "exit") { + exitOnboardFromPrompt(); + } const index = parseInt(choice || "1", 10) - 1; if (index >= 0 && index < CLOUD_MODEL_OPTIONS.length) { return CLOUD_MODEL_OPTIONS[index].id; } - return promptManualModelId( - " NVIDIA Endpoints model id: ", - "NVIDIA Endpoints", - (model) => validateNvidiaEndpointModel(model, getCredential("NVIDIA_API_KEY")) + return promptManualModelId(" NVIDIA Endpoints model id: ", "NVIDIA Endpoints", (model) => + validateNvidiaEndpointModel(model, getCredential("NVIDIA_API_KEY")), ); } @@ -970,6 +1367,13 @@ async function promptRemoteModel(label, providerKey, defaultModel, validator = n console.log(""); const choice = await prompt(` Choose model [${defaultIndex + 1}]: `); + const navigation = getNavigationChoice(choice); + if (navigation === "back") { + return BACK_TO_SELECTION; + } + if (navigation === "exit") { + exitOnboardFromPrompt(); + } const index = parseInt(choice || String(defaultIndex + 1), 10) - 1; if (index >= 0 && index < options.length) { return options[index]; @@ -981,6 +1385,13 @@ async function promptRemoteModel(label, providerKey, defaultModel, validator = n async function promptInputModel(label, defaultModel, validator = null) { while (true) { const value = await prompt(` ${label} model [${defaultModel}]: `); + const navigation = getNavigationChoice(value); + if (navigation === "back") { + return BACK_TO_SELECTION; + } + if (navigation === "exit") { + exitOnboardFromPrompt(); + } const trimmed = (value || defaultModel).trim(); if (!trimmed || !isSafeModelId(trimmed)) { console.error(` Invalid ${label} model id.`); @@ -1028,8 +1439,15 @@ function pullOllamaModel(model) { cwd: ROOT, encoding: "utf8", stdio: "inherit", + timeout: 600_000, env: { ...process.env }, }); + if (result.signal === "SIGTERM") { + console.error( + ` Model pull timed out after 10 minutes. Try a smaller model or check your network connection.`, + ); + return false; + } return result.status === 0; } @@ -1052,6 +1470,90 @@ function prepareOllamaModel(model, installedModels = []) { return validateOllamaModel(model, runCapture); } +function getRequestedSandboxNameHint() { + const raw = process.env.NEMOCLAW_SANDBOX_NAME; + if (typeof raw !== "string") return null; + const normalized = raw.trim().toLowerCase(); + return normalized || null; +} + +function getResumeSandboxConflict(session) { + const requestedSandboxName = getRequestedSandboxNameHint(); + if (!requestedSandboxName || !session?.sandboxName) { + return null; + } + return requestedSandboxName !== session.sandboxName + ? { requestedSandboxName, recordedSandboxName: session.sandboxName } + : null; +} + +function getRequestedProviderHint(nonInteractive = isNonInteractive()) { + return nonInteractive ? getNonInteractiveProvider() : null; +} + +function getRequestedModelHint(nonInteractive = isNonInteractive()) { + if (!nonInteractive) return null; + const providerKey = getRequestedProviderHint(nonInteractive) || "cloud"; + return getNonInteractiveModel(providerKey); +} + +function getEffectiveProviderName(providerKey) { + if (!providerKey) return null; + if (REMOTE_PROVIDER_CONFIG[providerKey]) { + return REMOTE_PROVIDER_CONFIG[providerKey].providerName; + } + + switch (providerKey) { + case "nim-local": + return "nvidia-nim"; + case "ollama": + return "ollama-local"; + case "vllm": + return "vllm-local"; + default: + return providerKey; + } +} + +function getResumeConfigConflicts(session, opts = {}) { + const conflicts = []; + const nonInteractive = opts.nonInteractive ?? isNonInteractive(); + + const sandboxConflict = getResumeSandboxConflict(session); + if (sandboxConflict) { + conflicts.push({ + field: "sandbox", + requested: sandboxConflict.requestedSandboxName, + recorded: sandboxConflict.recordedSandboxName, + }); + } + + const requestedProvider = getRequestedProviderHint(nonInteractive); + const effectiveRequestedProvider = getEffectiveProviderName(requestedProvider); + if ( + effectiveRequestedProvider && + session?.provider && + effectiveRequestedProvider !== session.provider + ) { + conflicts.push({ + field: "provider", + requested: effectiveRequestedProvider, + recorded: session.provider, + }); + } + + const requestedModel = getRequestedModelHint(nonInteractive); + if (requestedModel && session?.model && requestedModel !== session.model) { + conflicts.push({ + field: "model", + requested: requestedModel, + recorded: session.model, + }); + } + + return conflicts; +} + function isDockerRunning() { try { runCapture("docker info", { ignoreError: false }); @@ -1077,12 +1579,28 @@ function getFutureShellPathHint(binDir, pathValue = process.env.PATH || "") { return `export PATH="${binDir}:$PATH"`; } +function getPortConflictServiceHints(platform = process.platform) { + if (platform === "darwin") { + return [ + " # or, if it's a launchctl service (macOS):", + " launchctl list | grep -i claw # columns: PID | ExitStatus | Label", + ` launchctl unload ${OPENCLAW_LAUNCH_AGENT_PLIST}`, + " # or: launchctl bootout gui/$(id -u)/ai.openclaw.gateway", + ]; + } + return [ + " # or, if it's a systemd service:", + " systemctl --user stop openclaw-gateway.service", + ]; +} + function installOpenshell() { const result = spawnSync("bash", [path.join(SCRIPTS, "install-openshell.sh")], { cwd: ROOT, env: process.env, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8", + timeout: 300_000, }); if (result.status !== 0) { const output = `${result.stdout || ""}${result.stderr || ""}`.trim(); @@ -1111,38 +1629,42 @@ function sleep(seconds) { require("child_process").spawnSync("sleep", [String(seconds)]); } +function destroyGateway() { + runOpenshell(["gateway", "destroy", "-g", GATEWAY_NAME], { ignoreError: true }); + // openshell gateway destroy doesn't remove Docker volumes, which leaves + // corrupted cluster state that breaks the next gateway start. Clean them up. + run( + `docker volume ls -q --filter "name=openshell-cluster-${GATEWAY_NAME}" | grep . && docker volume ls -q --filter "name=openshell-cluster-${GATEWAY_NAME}" | xargs docker volume rm || true`, + { ignoreError: true }, + ); +} + async function ensureNamedCredential(envName, label, helpUrl = null) { let key = getCredential(envName); if (key) { process.env[envName] = key; return key; } - - if (helpUrl) { - console.log(""); - console.log(` Get your ${label} from: ${helpUrl}`); - console.log(""); - } - - key = await prompt(` ${label}: `, { secret: true }); - if (!key) { - console.error(` ${label} is required.`); - process.exit(1); - } - - saveCredential(envName, key); - process.env[envName] = key; - console.log(""); - console.log(` Key saved to ~/.nemoclaw/credentials.json (mode 600)`); - console.log(""); - return key; + return replaceNamedCredential(envName, label, helpUrl); } function waitForSandboxReady(sandboxName, attempts = 10, delaySeconds = 2) { for (let i = 0; i < attempts; i += 1) { const podPhase = runCaptureOpenshell( - ["doctor", "exec", "--", "kubectl", "-n", "openshell", "get", "pod", sandboxName, "-o", "jsonpath={.status.phase}"], - { ignoreError: true } + [ + "doctor", + "exec", + "--", + "kubectl", + "-n", + "openshell", + "get", + "pod", + sandboxName, + "-o", + "jsonpath={.status.phase}", + ], + { ignoreError: true }, ); if (podPhase === "Running") return true; sleep(delaySeconds); @@ -1150,16 +1672,8 @@ function waitForSandboxReady(sandboxName, attempts = 10, delaySeconds = 2) { return false; } -function parsePolicyPresetEnv(value) { - return (value || "") - .split(",") - .map((s) => s.trim()) - .filter(Boolean); -} - -function isSafeModelId(value) { - return /^[A-Za-z0-9._:/-]+$/.test(value); -} +// parsePolicyPresetEnv — see urlUtils import above +// isSafeModelId — see validation import above function getNonInteractiveProvider() { const providerKey = (process.env.NEMOCLAW_PROVIDER || "").trim().toLowerCase(); @@ -1171,10 +1685,22 @@ function getNonInteractiveProvider() { anthropiccompatible: "anthropicCompatible", }; const normalized = aliases[providerKey] || providerKey; - const validProviders = new Set(["build", "openai", "anthropic", "anthropicCompatible", "gemini", "ollama", "custom", "nim-local", "vllm"]); + const validProviders = new Set([ + "build", + "openai", + "anthropic", + "anthropicCompatible", + "gemini", + "ollama", + "custom", + "nim-local", + "vllm", + ]); if (!validProviders.has(normalized)) { console.error(` Unsupported NEMOCLAW_PROVIDER: ${providerKey}`); - console.error(" Valid values: build, openai, anthropic, anthropicCompatible, gemini, ollama, custom, nim-local, vllm"); + console.error( + " Valid values: build, openai, anthropic, anthropicCompatible, gemini, ollama, custom, nim-local, vllm", + ); process.exit(1); } @@ -1194,6 +1720,7 @@ function getNonInteractiveModel(providerKey) { // ── Step 1: Preflight ──────────────────────────────────────────── +// eslint-disable-next-line complexity async function preflight() { step(1, 7, "Preflight checks"); @@ -1207,7 +1734,9 @@ async function preflight() { const runtime = getContainerRuntime(); if (isUnsupportedMacosRuntime(runtime)) { console.error(" Podman on macOS is not supported by NemoClaw at this time."); - console.error(" OpenShell currently depends on Docker host-gateway behavior that Podman on macOS does not provide."); + console.error( + " OpenShell currently depends on Docker host-gateway behavior that Podman on macOS does not provide.", + ); console.error(" Use Colima or Docker Desktop on macOS instead."); process.exit(1); } @@ -1226,19 +1755,30 @@ async function preflight() { process.exit(1); } } - console.log(` ✓ openshell CLI: ${runCaptureOpenshell(["--version"], { ignoreError: true }) || "unknown"}`); + console.log( + ` ✓ openshell CLI: ${runCaptureOpenshell(["--version"], { ignoreError: true }) || "unknown"}`, + ); if (openshellInstall.futureShellPathHint) { - console.log(` Note: openshell was installed to ${openshellInstall.localBin} for this onboarding run.`); + console.log( + ` Note: openshell was installed to ${openshellInstall.localBin} for this onboarding run.`, + ); console.log(` Future shells may still need: ${openshellInstall.futureShellPathHint}`); - console.log(" Add that export to your shell profile, or open a new terminal before running openshell directly."); + console.log( + " Add that export to your shell profile, or open a new terminal before running openshell directly.", + ); } - // Clean up stale NemoClaw session before checking ports. - // A previous onboard run may have left the gateway container and port - // forward running. If a NemoClaw-owned gateway is still present, tear - // it down so the port check below doesn't fail on our own leftovers. - const gwInfo = runCaptureOpenshell(["gateway", "info", "-g", GATEWAY_NAME], { ignoreError: true }); - if (hasStaleGateway(gwInfo)) { + // Clean up stale or unnamed NemoClaw gateway state before checking ports. + // A healthy named gateway can be reused later in onboarding, so avoid + // tearing it down here. If some other gateway is active, do not treat it + // as NemoClaw state; let the port checks surface the conflict instead. + const gatewayStatus = runCaptureOpenshell(["status"], { ignoreError: true }); + const gwInfo = runCaptureOpenshell(["gateway", "info", "-g", GATEWAY_NAME], { + ignoreError: true, + }); + const activeGatewayInfo = runCaptureOpenshell(["gateway", "info"], { ignoreError: true }); + const gatewayReuseState = getGatewayReuseState(gatewayStatus, gwInfo, activeGatewayInfo); + if (gatewayReuseState === "stale" || gatewayReuseState === "active-unnamed") { console.log(" Cleaning up previous NemoClaw session..."); runOpenshell(["forward", "stop", "18789"], { ignoreError: true }); runOpenshell(["gateway", "destroy", "-g", GATEWAY_NAME], { ignoreError: true }); @@ -1253,25 +1793,32 @@ async function preflight() { for (const { port, label } of requiredPorts) { const portCheck = await checkPortAvailable(port); if (!portCheck.ok) { + if ((port === 8080 || port === 18789) && gatewayReuseState === "healthy") { + console.log(` ✓ Port ${port} already owned by healthy NemoClaw runtime (${label})`); + continue; + } console.error(""); console.error(` !! Port ${port} is not available.`); console.error(` ${label} needs this port.`); console.error(""); if (portCheck.process && portCheck.process !== "unknown") { - console.error(` Blocked by: ${portCheck.process}${portCheck.pid ? ` (PID ${portCheck.pid})` : ""}`); + console.error( + ` Blocked by: ${portCheck.process}${portCheck.pid ? ` (PID ${portCheck.pid})` : ""}`, + ); console.error(""); console.error(" To fix, stop the conflicting process:"); console.error(""); if (portCheck.pid) { console.error(` sudo kill ${portCheck.pid}`); } else { - console.error(` lsof -i :${port} -sTCP:LISTEN -P -n`); + console.error(` sudo lsof -i :${port} -sTCP:LISTEN -P -n`); + } + for (const hint of getPortConflictServiceHints()) { + console.error(hint); } - console.error(" # or, if it's a systemd service:"); - console.error(" systemctl --user stop openclaw-gateway.service"); } else { console.error(` Could not identify the process using port ${port}.`); - console.error(` Run: lsof -i :${port} -sTCP:LISTEN`); + console.error(` Run: sudo lsof -i :${port} -sTCP:LISTEN`); } console.error(""); console.error(` Detail: ${portCheck.reason}`); @@ -1284,23 +1831,88 @@ async function preflight() { const gpu = nim.detectGpu(); if (gpu && gpu.type === "nvidia") { console.log(` ✓ NVIDIA GPU detected: ${gpu.count} GPU(s), ${gpu.totalMemoryMB} MB VRAM`); + if (!gpu.nimCapable) { + console.log(" ⓘ GPU VRAM too small for local NIM — will use cloud inference"); + } } else if (gpu && gpu.type === "apple") { - console.log(` ✓ Apple GPU detected: ${gpu.name}${gpu.cores ? ` (${gpu.cores} cores)` : ""}, ${gpu.totalMemoryMB} MB unified memory`); + console.log( + ` ✓ Apple GPU detected: ${gpu.name}${gpu.cores ? ` (${gpu.cores} cores)` : ""}, ${gpu.totalMemoryMB} MB unified memory`, + ); console.log(" ⓘ NIM requires NVIDIA GPU — will use cloud inference"); } else { console.log(" ⓘ No GPU detected — will use cloud inference"); } + // Memory / swap check (Linux only) + if (process.platform === "linux") { + const mem = getMemoryInfo(); + if (mem) { + if (mem.totalMB < 12000) { + console.log( + ` ⚠ Low memory detected (${mem.totalRamMB} MB RAM + ${mem.totalSwapMB} MB swap = ${mem.totalMB} MB total)`, + ); + + let proceedWithSwap = false; + if (!isNonInteractive()) { + const answer = await prompt( + " Create a 4 GB swap file to prevent OOM during sandbox build? (requires sudo) [y/N]: ", + ); + proceedWithSwap = answer && answer.toLowerCase().startsWith("y"); + } + + if (!proceedWithSwap) { + console.log( + " ⓘ Skipping swap creation. Sandbox build may fail with OOM on this system.", + ); + } else { + console.log(" Creating 4 GB swap file to prevent OOM during sandbox build..."); + const swapResult = ensureSwap(12000); + if (swapResult.ok && swapResult.swapCreated) { + console.log(" ✓ Swap file created and activated"); + } else if (swapResult.ok) { + if (swapResult.reason) { + console.log(` ⓘ ${swapResult.reason} — existing swap should help prevent OOM`); + } else { + console.log(` ✓ Memory OK: ${mem.totalRamMB} MB RAM + ${mem.totalSwapMB} MB swap`); + } + } else { + console.log(` ⚠ Could not create swap: ${swapResult.reason}`); + console.log(" Sandbox creation may fail with OOM on low-memory systems."); + } + } + } else { + console.log(` ✓ Memory OK: ${mem.totalRamMB} MB RAM + ${mem.totalSwapMB} MB swap`); + } + } + } + return gpu; } // ── Step 2: Gateway ────────────────────────────────────────────── -async function startGateway(_gpu) { - step(3, 7, "Starting OpenShell gateway"); +async function startGatewayWithOptions(_gpu, { exitOnFailure = true } = {}) { + step(2, 7, "Starting OpenShell gateway"); - // Destroy old gateway - runOpenshell(["gateway", "destroy", "-g", GATEWAY_NAME], { ignoreError: true }); + const gatewayStatus = runCaptureOpenshell(["status"], { ignoreError: true }); + const gwInfo = runCaptureOpenshell(["gateway", "info", "-g", GATEWAY_NAME], { + ignoreError: true, + }); + const activeGatewayInfo = runCaptureOpenshell(["gateway", "info"], { ignoreError: true }); + if (isGatewayHealthy(gatewayStatus, gwInfo, activeGatewayInfo)) { + console.log(" ✓ Reusing existing gateway"); + runOpenshell(["gateway", "select", GATEWAY_NAME], { ignoreError: true }); + process.env.OPENSHELL_GATEWAY = GATEWAY_NAME; + return; + } + + // When a stale gateway is detected (metadata exists but container is gone, + // e.g. after a Docker/Colima restart), skip the destroy — `gateway start` + // can recover the container without wiping metadata and mTLS certs. + // The retry loop below will destroy only if start genuinely fails. + if (hasStaleGateway(gwInfo)) { + console.log(" Stale gateway detected — attempting restart without destroy..."); + } const gwArgs = ["--name", GATEWAY_NAME]; // Do NOT pass --gpu here. On DGX Spark (and most GPU hosts), inference is @@ -1308,6 +1920,89 @@ async function startGateway(_gpu) { // sandbox itself does not need direct GPU access. Passing --gpu causes // FailedPrecondition errors when the gateway's k3s device plugin cannot // allocate GPUs. See: https://build.nvidia.com/spark/nemoclaw/instructions + const gatewayEnv = getGatewayStartEnv(); + if (gatewayEnv.OPENSHELL_CLUSTER_IMAGE) { + console.log(` Using pinned OpenShell gateway image: ${gatewayEnv.OPENSHELL_CLUSTER_IMAGE}`); + } + + // Retry gateway start with exponential backoff. On some hosts (Horde VMs, + // first-run environments) the embedded k3s needs more time than OpenShell's + // internal health-check window allows. Retrying after a clean destroy lets + // the second attempt benefit from cached images and cleaner cgroup state. + // See: https://github.com/NVIDIA/OpenShell/issues/433 + const retries = exitOnFailure ? 2 : 0; + try { + await pRetry( + () => { + runOpenshell(["gateway", "start", ...gwArgs], { ignoreError: true, env: gatewayEnv }); + + const healthPollCount = envInt("NEMOCLAW_HEALTH_POLL_COUNT", 5); + const healthPollInterval = envInt("NEMOCLAW_HEALTH_POLL_INTERVAL", 2); + for (let i = 0; i < healthPollCount; i++) { + const status = runCaptureOpenshell(["status"], { ignoreError: true }); + const namedInfo = runCaptureOpenshell(["gateway", "info", "-g", GATEWAY_NAME], { + ignoreError: true, + }); + const currentInfo = runCaptureOpenshell(["gateway", "info"], { ignoreError: true }); + if (isGatewayHealthy(status, namedInfo, currentInfo)) { + return; // success + } + if (i < healthPollCount - 1) sleep(healthPollInterval); + } + + throw new Error("Gateway failed to start"); + }, + { + retries, + minTimeout: 10_000, + factor: 3, + onFailedAttempt: (err) => { + console.log( + ` Gateway start attempt ${err.attemptNumber} failed. ${err.retriesLeft} retries left...`, + ); + if (err.retriesLeft > 0 && exitOnFailure) { + destroyGateway(); + } + }, + }, + ); + } catch { + if (exitOnFailure) { + console.error(` Gateway failed to start after ${retries + 1} attempts.`); + console.error(" Gateway state preserved for diagnostics."); + console.error(""); + console.error(" Troubleshooting:"); + console.error(" openshell doctor logs --name nemoclaw"); + console.error(" openshell doctor check"); + process.exit(1); + } + throw new Error("Gateway failed to start"); + } + + console.log(" ✓ Gateway is healthy"); + + // CoreDNS fix — k3s-inside-Docker has broken DNS forwarding on all platforms. + const runtime = getContainerRuntime(); + if (shouldPatchCoredns(runtime)) { + console.log(" Patching CoreDNS DNS forwarding..."); + run(`bash "${path.join(SCRIPTS, "fix-coredns.sh")}" ${GATEWAY_NAME} 2>&1 || true`, { + ignoreError: true, + }); + } + sleep(5); + runOpenshell(["gateway", "select", GATEWAY_NAME], { ignoreError: true }); + process.env.OPENSHELL_GATEWAY = GATEWAY_NAME; +} + +async function startGateway(_gpu) { + return startGatewayWithOptions(_gpu, { exitOnFailure: true }); +} + +async function startGatewayForRecovery(_gpu) { + return startGatewayWithOptions(_gpu, { exitOnFailure: false }); +} + +function getGatewayStartEnv() { const gatewayEnv = {}; const openshellVersion = getInstalledOpenshellVersion(); const stableGatewayImage = openshellVersion @@ -1316,74 +2011,111 @@ async function startGateway(_gpu) { if (stableGatewayImage && openshellVersion) { gatewayEnv.OPENSHELL_CLUSTER_IMAGE = stableGatewayImage; gatewayEnv.IMAGE_TAG = openshellVersion; - console.log(` Using pinned OpenShell gateway image: ${stableGatewayImage}`); } + return gatewayEnv; +} - runOpenshell(["gateway", "start", ...gwArgs], { ignoreError: false, env: gatewayEnv }); +async function recoverGatewayRuntime() { + runOpenshell(["gateway", "select", GATEWAY_NAME], { ignoreError: true }); + let status = runCaptureOpenshell(["status"], { ignoreError: true }); + if (status.includes("Connected") && isSelectedGateway(status)) { + process.env.OPENSHELL_GATEWAY = GATEWAY_NAME; + return true; + } - // Verify health - for (let i = 0; i < 5; i++) { - const status = runCaptureOpenshell(["status"], { ignoreError: true }); - if (status.includes("Connected")) { - console.log(" ✓ Gateway is healthy"); - break; - } - if (i === 4) { - console.error(" Gateway failed to start. Run: openshell gateway info"); - process.exit(1); + runOpenshell(["gateway", "start", "--name", GATEWAY_NAME], { + ignoreError: true, + env: getGatewayStartEnv(), + }); + runOpenshell(["gateway", "select", GATEWAY_NAME], { ignoreError: true }); + + const recoveryPollCount = envInt("NEMOCLAW_HEALTH_POLL_COUNT", 10); + const recoveryPollInterval = envInt("NEMOCLAW_HEALTH_POLL_INTERVAL", 2); + for (let i = 0; i < recoveryPollCount; i++) { + status = runCaptureOpenshell(["status"], { ignoreError: true }); + if (status.includes("Connected") && isSelectedGateway(status)) { + process.env.OPENSHELL_GATEWAY = GATEWAY_NAME; + const runtime = getContainerRuntime(); + if (shouldPatchCoredns(runtime)) { + run(`bash "${path.join(SCRIPTS, "fix-coredns.sh")}" ${GATEWAY_NAME} 2>&1 || true`, { + ignoreError: true, + }); + } + return true; } - sleep(2); + if (i < recoveryPollCount - 1) sleep(recoveryPollInterval); } - // CoreDNS fix — always run. k3s-inside-Docker has broken DNS on all platforms. - const runtime = getContainerRuntime(); - if (shouldPatchCoredns(runtime)) { - console.log(" Patching CoreDNS for Colima..."); - run(`bash "${path.join(SCRIPTS, "fix-coredns.sh")}" ${GATEWAY_NAME} 2>&1 || true`, { ignoreError: true }); - } - // Give DNS a moment to propagate - sleep(5); - runOpenshell(["gateway", "select", GATEWAY_NAME], { ignoreError: true }); - process.env.OPENSHELL_GATEWAY = GATEWAY_NAME; + return false; } // ── Step 3: Sandbox ────────────────────────────────────────────── -async function createSandbox(gpu, model, provider, preferredInferenceApi = null) { - step(5, 7, "Creating sandbox"); +async function promptValidatedSandboxName() { + while (true) { + const nameAnswer = await promptOrDefault( + " Sandbox name (lowercase, numbers, hyphens) [my-assistant]: ", + "NEMOCLAW_SANDBOX_NAME", + "my-assistant", + ); + const sandboxName = (nameAnswer || "my-assistant").trim().toLowerCase(); - const nameAnswer = await promptOrDefault( - " Sandbox name (lowercase, numbers, hyphens) [my-assistant]: ", - "NEMOCLAW_SANDBOX_NAME", "my-assistant" - ); - const sandboxName = (nameAnswer || "my-assistant").trim().toLowerCase(); + // Validate: RFC 1123 subdomain — lowercase alphanumeric and hyphens, + // must start and end with alphanumeric (required by Kubernetes/OpenShell) + if (/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(sandboxName)) { + return sandboxName; + } - // Validate: RFC 1123 subdomain — lowercase alphanumeric and hyphens, - // must start and end with alphanumeric (required by Kubernetes/OpenShell) - if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(sandboxName)) { console.error(` Invalid sandbox name: '${sandboxName}'`); console.error(" Names must be lowercase, contain only letters, numbers, and hyphens,"); console.error(" and must start and end with a letter or number."); - process.exit(1); + + // Non-interactive runs cannot re-prompt — abort so the caller can fix the + // NEMOCLAW_SANDBOX_NAME env var and retry. + if (isNonInteractive()) { + process.exit(1); + } + + console.error(" Please try again.\n"); } +} + +// ── Step 5: Sandbox ────────────────────────────────────────────── + +// eslint-disable-next-line complexity +async function createSandbox( + gpu, + model, + provider, + preferredInferenceApi = null, + sandboxNameOverride = null, +) { + step(5, 7, "Creating sandbox"); + + const sandboxName = sandboxNameOverride || (await promptValidatedSandboxName()); + const chatUiUrl = process.env.CHAT_UI_URL || `http://127.0.0.1:${CONTROL_UI_PORT}`; // Reconcile local registry state with the live OpenShell gateway state. const liveExists = pruneStaleSandboxEntry(sandboxName); if (liveExists) { - if (isNonInteractive()) { - if (process.env.NEMOCLAW_RECREATE_SANDBOX !== "1") { - console.error(` Sandbox '${sandboxName}' already exists.`); - console.error(" Set NEMOCLAW_RECREATE_SANDBOX=1 to recreate it in non-interactive mode."); - process.exit(1); + const existingSandboxState = getSandboxReuseState(sandboxName); + if (existingSandboxState === "ready" && process.env.NEMOCLAW_RECREATE_SANDBOX !== "1") { + ensureDashboardForward(sandboxName, chatUiUrl); + if (isNonInteractive()) { + note(` [non-interactive] Sandbox '${sandboxName}' exists and is ready — reusing it`); + } else { + console.log(` Sandbox '${sandboxName}' already exists and is ready.`); + console.log(" Reusing existing sandbox."); + console.log(" Set NEMOCLAW_RECREATE_SANDBOX=1 to recreate it instead."); } - note(` [non-interactive] Sandbox '${sandboxName}' exists — recreating`); + return sandboxName; + } + + if (existingSandboxState === "ready") { + note(` Sandbox '${sandboxName}' exists and is ready — recreating by explicit request.`); } else { - const recreate = await prompt(` Sandbox '${sandboxName}' already exists. Recreate? [y/N]: `); - if (recreate.toLowerCase() !== "y") { - console.log(" Keeping existing sandbox."); - return sandboxName; - } + note(` Sandbox '${sandboxName}' exists but is not ready — recreating it.`); } // Destroy old sandbox runOpenshell(["sandbox", "delete", sandboxName], { ignoreError: true }); @@ -1394,24 +2126,35 @@ async function createSandbox(gpu, model, provider, preferredInferenceApi = null) const buildCtx = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-build-")); const stagedDockerfile = path.join(buildCtx, "Dockerfile"); fs.copyFileSync(path.join(ROOT, "Dockerfile"), stagedDockerfile); - run(`cp -r "${path.join(ROOT, "nemoclaw")}" "${buildCtx}/nemoclaw"`); - run(`cp -r "${path.join(ROOT, "nemoclaw-blueprint")}" "${buildCtx}/nemoclaw-blueprint"`); - run(`cp -r "${path.join(ROOT, "scripts")}" "${buildCtx}/scripts"`); - run(`rm -rf "${buildCtx}/nemoclaw/node_modules"`, { ignoreError: true }); + copyBuildContextDir(path.join(ROOT, "nemoclaw"), path.join(buildCtx, "nemoclaw")); + copyBuildContextDir( + path.join(ROOT, "nemoclaw-blueprint"), + path.join(buildCtx, "nemoclaw-blueprint"), + ); + copyBuildContextDir(path.join(ROOT, "scripts"), path.join(buildCtx, "scripts")); // Create sandbox (use -- echo to avoid dropping into interactive shell) // Pass the base policy so sandbox starts in proxy mode (required for policy updates later) const basePolicyPath = path.join(ROOT, "nemoclaw-blueprint", "policies", "openclaw-sandbox.yaml"); const createArgs = [ - "--from", `${buildCtx}/Dockerfile`, - "--name", sandboxName, - "--policy", basePolicyPath, + "--from", + `${buildCtx}/Dockerfile`, + "--name", + sandboxName, + "--policy", + basePolicyPath, ]; // --gpu is intentionally omitted. See comment in startGateway(). console.log(` Creating sandbox '${sandboxName}' (this takes a few minutes on first run)...`); - const chatUiUrl = process.env.CHAT_UI_URL || "http://127.0.0.1:18789"; - patchStagedDockerfile(stagedDockerfile, model, chatUiUrl, String(Date.now()), provider, preferredInferenceApi); + patchStagedDockerfile( + stagedDockerfile, + model, + chatUiUrl, + String(Date.now()), + provider, + preferredInferenceApi, + ); // Only pass non-sensitive env vars to the sandbox. NVIDIA_API_KEY is NOT // needed inside the sandbox — inference is proxied through the OpenShell // gateway which injects the stored credential server-side. The gateway @@ -1429,7 +2172,6 @@ async function createSandbox(gpu, model, provider, preferredInferenceApi = null) if (slackToken) { sandboxEnv.SLACK_BOT_TOKEN = slackToken; } - // Run without piping through awk — the pipe masked non-zero exit codes // from openshell because bash returns the status of the last pipeline // command (awk, always 0) unless pipefail is set. Removing the pipe @@ -1461,7 +2203,7 @@ async function createSandbox(gpu, model, provider, preferredInferenceApi = null) console.error(createResult.output); } console.error(" Try: openshell sandbox list # check gateway state"); - console.error(" Try: nemoclaw onboard # retry from scratch"); + printSandboxCreateRecoveryHints(createResult.output); process.exit(createResult.status || 1); } @@ -1477,7 +2219,7 @@ async function createSandbox(gpu, model, provider, preferredInferenceApi = null) ready = true; break; } - require("child_process").spawnSync("sleep", ["2"]); + sleep(2); } if (!ready) { @@ -1496,12 +2238,30 @@ async function createSandbox(gpu, model, provider, preferredInferenceApi = null) process.exit(1); } + // Wait for NemoClaw dashboard to become fully ready (web server live) + // This prevents port forwards from connecting to a non-existent port + // or seeing 502/503 errors during initial load. + console.log(" Waiting for NemoClaw dashboard to become ready..."); + for (let i = 0; i < 15; i++) { + const readyMatch = runCapture( + `openshell sandbox exec ${sandboxName} curl -sf http://localhost:18789/ 2>/dev/null || echo "no"`, + { ignoreError: true }, + ); + if (readyMatch && !readyMatch.includes("no")) { + console.log(" ✓ Dashboard is live"); + break; + } + if (i === 14) { + console.warn(" Dashboard taking longer than expected to start. Continuing..."); + } else { + sleep(2); + } + } + // Release any stale forward on port 18789 before claiming it for the new sandbox. // A previous onboard run may have left the port forwarded to a different sandbox, // which would silently prevent the new sandbox's dashboard from being reachable. - runOpenshell(["forward", "stop", "18789"], { ignoreError: true }); - // Forward dashboard port to the new sandbox - runOpenshell(["forward", "start", "--background", "18789", sandboxName], { ignoreError: true }); + ensureDashboardForward(sandboxName, chatUiUrl); // Register only after confirmed ready — prevents phantom entries registry.registerSandbox({ @@ -1509,14 +2269,23 @@ async function createSandbox(gpu, model, provider, preferredInferenceApi = null) gpuEnabled: !!gpu, }); + // DNS proxy — run a forwarder in the sandbox pod so the isolated + // sandbox namespace can resolve hostnames (fixes #626). + console.log(" Setting up sandbox DNS proxy..."); + run( + `bash "${path.join(SCRIPTS, "setup-dns-proxy.sh")}" ${GATEWAY_NAME} "${sandboxName}" 2>&1 || true`, + { ignoreError: true }, + ); + console.log(` ✓ Sandbox '${sandboxName}' created`); return sandboxName; } -// ── Step 4: NIM ────────────────────────────────────────────────── +// ── Step 3: Inference selection ────────────────────────────────── +// eslint-disable-next-line complexity async function setupNim(gpu) { - step(2, 7, "Configuring inference (NIM)"); + step(3, 7, "Configuring inference (NIM)"); let model = null; let provider = REMOTE_PROVIDER_CONFIG.build.providerName; @@ -1527,17 +2296,18 @@ async function setupNim(gpu) { // Detect local inference options const hasOllama = !!runCapture("command -v ollama", { ignoreError: true }); - const ollamaRunning = !!runCapture("curl -sf http://localhost:11434/api/tags 2>/dev/null", { ignoreError: true }); - const vllmRunning = !!runCapture("curl -sf http://localhost:8000/v1/models 2>/dev/null", { ignoreError: true }); + const ollamaRunning = !!runCapture("curl -sf http://localhost:11434/api/tags 2>/dev/null", { + ignoreError: true, + }); + const vllmRunning = !!runCapture("curl -sf http://localhost:8000/v1/models 2>/dev/null", { + ignoreError: true, + }); const requestedProvider = isNonInteractive() ? getNonInteractiveProvider() : null; - const requestedModel = isNonInteractive() ? getNonInteractiveModel(requestedProvider || "build") : null; + const requestedModel = isNonInteractive() + ? getNonInteractiveModel(requestedProvider || "build") + : null; const options = []; - options.push({ - key: "build", - label: - "NVIDIA Endpoints" + - (!ollamaRunning && !(EXPERIMENTAL && vllmRunning) ? " (recommended)" : ""), - }); + options.push({ key: "build", label: "NVIDIA Endpoints" }); options.push({ key: "openai", label: "OpenAI" }); options.push({ key: "custom", label: "Other OpenAI-compatible endpoint" }); options.push({ key: "anthropic", label: "Anthropic" }); @@ -1560,379 +2330,623 @@ async function setupNim(gpu) { label: "Local vLLM [experimental] — running", }); } - // On macOS without Ollama, offer to install it if (!hasOllama && process.platform === "darwin") { options.push({ key: "install-ollama", label: "Install Ollama (macOS)" }); } if (options.length > 1) { - selectionLoop: - while (true) { - let selected; - - if (isNonInteractive()) { - const providerKey = requestedProvider || "build"; - selected = options.find((o) => o.key === providerKey); - if (!selected) { - console.error(` Requested provider '${providerKey}' is not available in this environment.`); - process.exit(1); - } - note(` [non-interactive] Provider: ${selected.key}`); - } else { - const suggestions = []; - if (vllmRunning) suggestions.push("vLLM"); - if (ollamaRunning) suggestions.push("Ollama"); - if (suggestions.length > 0) { - console.log(` Detected local inference option${suggestions.length > 1 ? "s" : ""}: ${suggestions.join(", ")}`); - console.log(" Select one explicitly to use it. Press Enter to keep NVIDIA Endpoints."); - console.log(""); - } - - console.log(""); - console.log(" Inference options:"); - options.forEach((o, i) => { - console.log(` ${i + 1}) ${o.label}`); - }); - console.log(""); - - const defaultIdx = options.findIndex((o) => o.key === "build") + 1; - const choice = await prompt(` Choose [${defaultIdx}]: `); - const idx = parseInt(choice || String(defaultIdx), 10) - 1; - selected = options[idx] || options[defaultIdx - 1]; - } - - if (REMOTE_PROVIDER_CONFIG[selected.key]) { - const remoteConfig = REMOTE_PROVIDER_CONFIG[selected.key]; - provider = remoteConfig.providerName; - credentialEnv = remoteConfig.credentialEnv; - endpointUrl = remoteConfig.endpointUrl; - preferredInferenceApi = null; - - if (selected.key === "custom") { - endpointUrl = isNonInteractive() - ? (process.env.NEMOCLAW_ENDPOINT_URL || "").trim() - : await prompt(" OpenAI-compatible base URL (e.g., https://openrouter.ai/api/v1): "); - if (!endpointUrl) { - console.error(" Endpoint URL is required for Other OpenAI-compatible endpoint."); + selectionLoop: while (true) { + let selected; + + if (isNonInteractive()) { + const providerKey = requestedProvider || "build"; + selected = options.find((o) => o.key === providerKey); + if (!selected) { + console.error( + ` Requested provider '${providerKey}' is not available in this environment.`, + ); process.exit(1); } - } else if (selected.key === "anthropicCompatible") { - endpointUrl = isNonInteractive() - ? (process.env.NEMOCLAW_ENDPOINT_URL || "").trim() - : await prompt(" Anthropic-compatible base URL (e.g., https://proxy.example.com): "); - if (!endpointUrl) { - console.error(" Endpoint URL is required for Other Anthropic-compatible endpoint."); - process.exit(1); + note(` [non-interactive] Provider: ${selected.key}`); + } else { + const suggestions = []; + if (vllmRunning) suggestions.push("vLLM"); + if (ollamaRunning) suggestions.push("Ollama"); + if (suggestions.length > 0) { + console.log( + ` Detected local inference option${suggestions.length > 1 ? "s" : ""}: ${suggestions.join(", ")}`, + ); + console.log(""); } + + console.log(""); + console.log(" Inference options:"); + options.forEach((o, i) => { + console.log(` ${i + 1}) ${o.label}`); + }); + console.log(""); + + const defaultIdx = options.findIndex((o) => o.key === "build") + 1; + const choice = await prompt(` Choose [${defaultIdx}]: `); + const idx = parseInt(choice || String(defaultIdx), 10) - 1; + selected = options[idx] || options[defaultIdx - 1]; } - if (selected.key === "build") { - if (isNonInteractive()) { - if (!process.env.NVIDIA_API_KEY) { - console.error(" NVIDIA_API_KEY is required for NVIDIA Endpoints in non-interactive mode."); - process.exit(1); + if (REMOTE_PROVIDER_CONFIG[selected.key]) { + const remoteConfig = REMOTE_PROVIDER_CONFIG[selected.key]; + provider = remoteConfig.providerName; + credentialEnv = remoteConfig.credentialEnv; + endpointUrl = remoteConfig.endpointUrl; + preferredInferenceApi = null; + + if (selected.key === "custom") { + const endpointInput = isNonInteractive() + ? (process.env.NEMOCLAW_ENDPOINT_URL || "").trim() + : await prompt(" OpenAI-compatible base URL (e.g., https://openrouter.ai/api/v1): "); + const navigation = getNavigationChoice(endpointInput); + if (navigation === "back") { + console.log(" Returning to provider selection."); + console.log(""); + continue selectionLoop; } - } else { - await ensureApiKey(); - } - model = requestedModel || (isNonInteractive() ? DEFAULT_CLOUD_MODEL : await promptCloudModel()) || DEFAULT_CLOUD_MODEL; - } else { - if (isNonInteractive()) { - if (!process.env[credentialEnv]) { - console.error(` ${credentialEnv} is required for ${remoteConfig.label} in non-interactive mode.`); - process.exit(1); + if (navigation === "exit") { + exitOnboardFromPrompt(); + } + endpointUrl = normalizeProviderBaseUrl(endpointInput, "openai"); + if (!endpointUrl) { + console.error(" Endpoint URL is required for Other OpenAI-compatible endpoint."); + if (isNonInteractive()) { + process.exit(1); + } + console.log(""); + continue selectionLoop; + } + } else if (selected.key === "anthropicCompatible") { + const endpointInput = isNonInteractive() + ? (process.env.NEMOCLAW_ENDPOINT_URL || "").trim() + : await prompt(" Anthropic-compatible base URL (e.g., https://proxy.example.com): "); + const navigation = getNavigationChoice(endpointInput); + if (navigation === "back") { + console.log(" Returning to provider selection."); + console.log(""); + continue selectionLoop; + } + if (navigation === "exit") { + exitOnboardFromPrompt(); + } + endpointUrl = normalizeProviderBaseUrl(endpointInput, "anthropic"); + if (!endpointUrl) { + console.error(" Endpoint URL is required for Other Anthropic-compatible endpoint."); + if (isNonInteractive()) { + process.exit(1); + } + console.log(""); + continue selectionLoop; } - } else { - await ensureNamedCredential(credentialEnv, remoteConfig.label + " API key", remoteConfig.helpUrl); - } - const defaultModel = requestedModel || remoteConfig.defaultModel; - let modelValidator = null; - if (selected.key === "openai" || selected.key === "gemini") { - modelValidator = (candidate) => - validateOpenAiLikeModel(remoteConfig.label, endpointUrl, candidate, getCredential(credentialEnv)); - } else if (selected.key === "anthropic") { - modelValidator = (candidate) => - validateAnthropicModel(endpointUrl || ANTHROPIC_ENDPOINT_URL, candidate, getCredential(credentialEnv)); } - while (true) { + + if (selected.key === "build") { if (isNonInteractive()) { - model = defaultModel; - } else if (remoteConfig.modelMode === "curated") { - model = await promptRemoteModel(remoteConfig.label, selected.key, defaultModel, modelValidator); + if (!process.env.NVIDIA_API_KEY) { + console.error( + " NVIDIA_API_KEY is required for NVIDIA Endpoints in non-interactive mode.", + ); + process.exit(1); + } } else { - model = await promptInputModel(remoteConfig.label, defaultModel, modelValidator); + await ensureApiKey(); } - - if (selected.key === "custom") { - const validation = await validateCustomOpenAiLikeSelection( - remoteConfig.label, - endpointUrl, - model, - credentialEnv - ); - if (validation.ok) { - preferredInferenceApi = validation.api; - break; - } - if (validation.retry === "selection") { - continue selectionLoop; + model = + requestedModel || + (isNonInteractive() ? DEFAULT_CLOUD_MODEL : await promptCloudModel()) || + DEFAULT_CLOUD_MODEL; + if (model === BACK_TO_SELECTION) { + console.log(" Returning to provider selection."); + console.log(""); + continue selectionLoop; + } + } else { + if (isNonInteractive()) { + if (!process.env[credentialEnv]) { + console.error( + ` ${credentialEnv} is required for ${remoteConfig.label} in non-interactive mode.`, + ); + process.exit(1); } - } else if (selected.key === "anthropicCompatible") { - const validation = await validateCustomAnthropicSelection( - remoteConfig.label, - endpointUrl || ANTHROPIC_ENDPOINT_URL, - model, - credentialEnv + } else { + await ensureNamedCredential( + credentialEnv, + remoteConfig.label + " API key", + remoteConfig.helpUrl, ); - if (validation.ok) { - preferredInferenceApi = validation.api; - break; + } + const defaultModel = requestedModel || remoteConfig.defaultModel; + let modelValidator = null; + if (selected.key === "openai" || selected.key === "gemini") { + modelValidator = (candidate) => + validateOpenAiLikeModel( + remoteConfig.label, + endpointUrl, + candidate, + getCredential(credentialEnv), + ); + } else if (selected.key === "anthropic") { + modelValidator = (candidate) => + validateAnthropicModel( + endpointUrl || ANTHROPIC_ENDPOINT_URL, + candidate, + getCredential(credentialEnv), + ); + } + while (true) { + if (isNonInteractive()) { + model = defaultModel; + } else if (remoteConfig.modelMode === "curated") { + model = await promptRemoteModel( + remoteConfig.label, + selected.key, + defaultModel, + modelValidator, + ); + } else { + model = await promptInputModel(remoteConfig.label, defaultModel, modelValidator); } - if (validation.retry === "selection") { + if (model === BACK_TO_SELECTION) { + console.log(" Returning to provider selection."); + console.log(""); continue selectionLoop; } - } else { - const retryMessage = "Please choose a provider/model again."; - if (selected.key === "anthropic") { - preferredInferenceApi = await validateAnthropicSelectionWithRetryMessage( + + if (selected.key === "custom") { + const validation = await validateCustomOpenAiLikeSelection( remoteConfig.label, - endpointUrl || ANTHROPIC_ENDPOINT_URL, + endpointUrl, model, credentialEnv, - retryMessage + remoteConfig.helpUrl, ); - } else { - preferredInferenceApi = await validateOpenAiLikeSelection( + if (validation.ok) { + preferredInferenceApi = validation.api; + break; + } + if ( + validation.retry === "credential" || + validation.retry === "retry" || + validation.retry === "model" + ) { + continue; + } + if (validation.retry === "selection") { + continue selectionLoop; + } + } else if (selected.key === "anthropicCompatible") { + const validation = await validateCustomAnthropicSelection( remoteConfig.label, - endpointUrl, + endpointUrl || ANTHROPIC_ENDPOINT_URL, model, credentialEnv, - retryMessage + remoteConfig.helpUrl, ); + if (validation.ok) { + preferredInferenceApi = validation.api; + break; + } + if ( + validation.retry === "credential" || + validation.retry === "retry" || + validation.retry === "model" + ) { + continue; + } + if (validation.retry === "selection") { + continue selectionLoop; + } + } else { + const retryMessage = "Please choose a provider/model again."; + if (selected.key === "anthropic") { + const validation = await validateAnthropicSelectionWithRetryMessage( + remoteConfig.label, + endpointUrl || ANTHROPIC_ENDPOINT_URL, + model, + credentialEnv, + retryMessage, + remoteConfig.helpUrl, + ); + if (validation.ok) { + preferredInferenceApi = validation.api; + break; + } + if ( + validation.retry === "credential" || + validation.retry === "retry" || + validation.retry === "model" + ) { + continue; + } + } else { + const validation = await validateOpenAiLikeSelection( + remoteConfig.label, + endpointUrl, + model, + credentialEnv, + retryMessage, + remoteConfig.helpUrl, + ); + if (validation.ok) { + preferredInferenceApi = validation.api; + break; + } + if ( + validation.retry === "credential" || + validation.retry === "retry" || + validation.retry === "model" + ) { + continue; + } + } + continue selectionLoop; } - if (preferredInferenceApi) { + } + } + + if (selected.key === "build") { + while (true) { + const validation = await validateOpenAiLikeSelection( + remoteConfig.label, + endpointUrl, + model, + credentialEnv, + "Please choose a provider/model again.", + remoteConfig.helpUrl, + ); + if (validation.ok) { + preferredInferenceApi = validation.api; break; } + if (validation.retry === "credential" || validation.retry === "retry") { + continue; + } continue selectionLoop; } } - } - if (selected.key === "build") { - preferredInferenceApi = await validateOpenAiLikeSelection( - remoteConfig.label, - endpointUrl, - model, - credentialEnv - ); - if (!preferredInferenceApi) { - continue selectionLoop; - } - } - - console.log(` Using ${remoteConfig.label} with model: ${model}`); - break; - } else if (selected.key === "nim-local") { - // List models that fit GPU VRAM - const models = nim.listModels().filter((m) => m.minGpuMemoryMB <= gpu.totalMemoryMB); - if (models.length === 0) { - console.log(" No NIM models fit your GPU VRAM. Falling back to cloud API."); - } else { - let sel; - if (isNonInteractive()) { - if (requestedModel) { - sel = models.find((m) => m.name === requestedModel); - if (!sel) { - console.error(` Unsupported NEMOCLAW_MODEL for NIM: ${requestedModel}`); - process.exit(1); + console.log(` Using ${remoteConfig.label} with model: ${model}`); + break; + } else if (selected.key === "nim-local") { + // List models that fit GPU VRAM + const models = nim.listModels().filter((m) => m.minGpuMemoryMB <= gpu.totalMemoryMB); + if (models.length === 0) { + console.log(" No NIM models fit your GPU VRAM. Falling back to cloud API."); + } else { + let sel; + if (isNonInteractive()) { + if (requestedModel) { + sel = models.find((m) => m.name === requestedModel); + if (!sel) { + console.error(` Unsupported NEMOCLAW_MODEL for NIM: ${requestedModel}`); + process.exit(1); + } + } else { + sel = models[0]; } + note(` [non-interactive] NIM model: ${sel.name}`); } else { - sel = models[0]; + console.log(""); + console.log(" Models that fit your GPU:"); + models.forEach((m, i) => { + console.log(` ${i + 1}) ${m.name} (min ${m.minGpuMemoryMB} MB)`); + }); + console.log(""); + + const modelChoice = await prompt(` Choose model [1]: `); + const midx = parseInt(modelChoice || "1", 10) - 1; + sel = models[midx] || models[0]; } - note(` [non-interactive] NIM model: ${sel.name}`); - } else { - console.log(""); - console.log(" Models that fit your GPU:"); - models.forEach((m, i) => { - console.log(` ${i + 1}) ${m.name} (min ${m.minGpuMemoryMB} MB)`); - }); - console.log(""); - - const modelChoice = await prompt(` Choose model [1]: `); - const midx = parseInt(modelChoice || "1", 10) - 1; - sel = models[midx] || models[0]; - } - model = sel.name; + model = sel.name; - console.log(` Pulling NIM image for ${model}...`); - nim.pullNimImage(model); + console.log(` Pulling NIM image for ${model}...`); + nim.pullNimImage(model); - console.log(" Starting NIM container..."); - nimContainer = nim.startNimContainerByName(nim.containerName(GATEWAY_NAME), model); + console.log(" Starting NIM container..."); + nimContainer = nim.startNimContainerByName(nim.containerName(GATEWAY_NAME), model); - console.log(" Waiting for NIM to become healthy..."); - if (!nim.waitForNimHealth()) { - console.error(" NIM failed to start. Falling back to cloud API."); - model = null; - nimContainer = null; - } else { - provider = "vllm-local"; - credentialEnv = "OPENAI_API_KEY"; - endpointUrl = getLocalProviderBaseUrl(provider); - preferredInferenceApi = await validateOpenAiLikeSelection( - "Local NVIDIA NIM", - endpointUrl, + console.log(" Waiting for NIM to become healthy..."); + if (!nim.waitForNimHealth()) { + console.error(" NIM failed to start. Falling back to cloud API."); + model = null; + nimContainer = null; + } else { + provider = "vllm-local"; + credentialEnv = "OPENAI_API_KEY"; + endpointUrl = getLocalProviderBaseUrl(provider); + const validation = await validateOpenAiLikeSelection( + "Local NVIDIA NIM", + endpointUrl, + model, + credentialEnv, + ); + if ( + validation.retry === "selection" || + validation.retry === "back" || + validation.retry === "model" + ) { + continue selectionLoop; + } + if (!validation.ok) { + continue selectionLoop; + } + preferredInferenceApi = validation.api; + // NIM uses vLLM internally — same tool-call-parser limitation + // applies to /v1/responses. Force chat completions. + if (preferredInferenceApi !== "openai-completions") { + console.log( + " ℹ Using chat completions API (tool-call-parser requires /v1/chat/completions)", + ); + } + preferredInferenceApi = "openai-completions"; + } + } + break; + } else if (selected.key === "ollama") { + if (!ollamaRunning) { + console.log(" Starting Ollama..."); + // On WSL2, binding to 0.0.0.0 creates a dual-stack socket that Docker + // cannot reach via host-gateway. The default 127.0.0.1 binding works + // because WSL2 relays IPv4-only sockets to the Windows host. + const ollamaEnv = isWsl() ? "" : "OLLAMA_HOST=0.0.0.0:11434 "; + run(`${ollamaEnv}ollama serve > /dev/null 2>&1 &`, { ignoreError: true }); + sleep(2); + } + console.log(" ✓ Using Ollama on localhost:11434"); + provider = "ollama-local"; + credentialEnv = "OPENAI_API_KEY"; + endpointUrl = getLocalProviderBaseUrl(provider); + while (true) { + const installedModels = getOllamaModelOptions(runCapture); + if (isNonInteractive()) { + model = requestedModel || getDefaultOllamaModel(runCapture, gpu); + } else { + model = await promptOllamaModel(gpu); + } + const probe = prepareOllamaModel(model, installedModels); + if (!probe.ok) { + console.error(` ${probe.message}`); + if (isNonInteractive()) { + process.exit(1); + } + console.log(" Choose a different Ollama model or select Other."); + console.log(""); + continue; + } + const validation = await validateOpenAiLikeSelection( + "Local Ollama", + getLocalProviderValidationBaseUrl(provider), model, - credentialEnv + null, + "Choose a different Ollama model or select Other.", ); - if (!preferredInferenceApi) { + if (validation.retry === "selection" || validation.retry === "back") { continue selectionLoop; } + if (!validation.ok) { + continue; + } + // Ollama's /v1/responses endpoint does not produce correctly + // formatted tool calls — force chat completions like vLLM/NIM. + if (validation.api !== "openai-completions") { + console.log( + " ℹ Using chat completions API (Ollama tool calls require /v1/chat/completions)", + ); + } + preferredInferenceApi = "openai-completions"; + break; } - } - break; - } else if (selected.key === "ollama") { - if (!ollamaRunning) { + break; + } else if (selected.key === "install-ollama") { + // macOS only — this option is gated by process.platform === "darwin" above + console.log(" Installing Ollama via Homebrew..."); + run("brew install ollama", { ignoreError: true }); console.log(" Starting Ollama..."); run("OLLAMA_HOST=0.0.0.0:11434 ollama serve > /dev/null 2>&1 &", { ignoreError: true }); sleep(2); - } - console.log(" ✓ Using Ollama on localhost:11434"); - provider = "ollama-local"; - credentialEnv = "OPENAI_API_KEY"; - endpointUrl = getLocalProviderBaseUrl(provider); - while (true) { - const installedModels = getOllamaModelOptions(runCapture); - if (isNonInteractive()) { - model = requestedModel || getDefaultOllamaModel(runCapture, gpu); - } else { - model = await promptOllamaModel(gpu); - } - const probe = prepareOllamaModel(model, installedModels); - if (!probe.ok) { - console.error(` ${probe.message}`); + console.log(" ✓ Using Ollama on localhost:11434"); + provider = "ollama-local"; + credentialEnv = "OPENAI_API_KEY"; + endpointUrl = getLocalProviderBaseUrl(provider); + while (true) { + const installedModels = getOllamaModelOptions(runCapture); if (isNonInteractive()) { - process.exit(1); + model = requestedModel || getDefaultOllamaModel(runCapture, gpu); + } else { + model = await promptOllamaModel(gpu); } - console.log(" Choose a different Ollama model or select Other."); - console.log(""); - continue; - } - preferredInferenceApi = await validateOpenAiLikeSelection( - "Local Ollama", - getLocalProviderValidationBaseUrl(provider), - model, - null, - "Choose a different Ollama model or select Other." - ); - if (!preferredInferenceApi) { - continue; + const probe = prepareOllamaModel(model, installedModels); + if (!probe.ok) { + console.error(` ${probe.message}`); + if (isNonInteractive()) { + process.exit(1); + } + console.log(" Choose a different Ollama model or select Other."); + console.log(""); + continue; + } + const validation = await validateOpenAiLikeSelection( + "Local Ollama", + getLocalProviderValidationBaseUrl(provider), + model, + null, + "Choose a different Ollama model or select Other.", + ); + if (validation.retry === "selection" || validation.retry === "back") { + continue selectionLoop; + } + if (!validation.ok) { + continue; + } + // Ollama's /v1/responses endpoint does not produce correctly + // formatted tool calls — force chat completions like vLLM/NIM. + if (validation.api !== "openai-completions") { + console.log( + " ℹ Using chat completions API (Ollama tool calls require /v1/chat/completions)", + ); + } + preferredInferenceApi = "openai-completions"; + break; } break; - } - break; - } else if (selected.key === "install-ollama") { - console.log(" Installing Ollama via Homebrew..."); - run("brew install ollama", { ignoreError: true }); - console.log(" Starting Ollama..."); - run("OLLAMA_HOST=0.0.0.0:11434 ollama serve > /dev/null 2>&1 &", { ignoreError: true }); - sleep(2); - console.log(" ✓ Using Ollama on localhost:11434"); - provider = "ollama-local"; - credentialEnv = "OPENAI_API_KEY"; - endpointUrl = getLocalProviderBaseUrl(provider); - while (true) { - const installedModels = getOllamaModelOptions(runCapture); - if (isNonInteractive()) { - model = requestedModel || getDefaultOllamaModel(runCapture, gpu); - } else { - model = await promptOllamaModel(gpu); - } - const probe = prepareOllamaModel(model, installedModels); - if (!probe.ok) { - console.error(` ${probe.message}`); - if (isNonInteractive()) { + } else if (selected.key === "vllm") { + console.log(" ✓ Using existing vLLM on localhost:8000"); + provider = "vllm-local"; + credentialEnv = "OPENAI_API_KEY"; + endpointUrl = getLocalProviderBaseUrl(provider); + // Query vLLM for the actual model ID + const vllmModelsRaw = runCapture("curl -sf http://localhost:8000/v1/models 2>/dev/null", { + ignoreError: true, + }); + try { + const vllmModels = JSON.parse(vllmModelsRaw); + if (vllmModels.data && vllmModels.data.length > 0) { + model = vllmModels.data[0].id; + if (!isSafeModelId(model)) { + console.error(` Detected model ID contains invalid characters: ${model}`); + process.exit(1); + } + console.log(` Detected model: ${model}`); + } else { + console.error(" Could not detect model from vLLM. Please specify manually."); process.exit(1); } - console.log(" Choose a different Ollama model or select Other."); - console.log(""); - continue; + } catch { + console.error( + " Could not query vLLM models endpoint. Is vLLM running on localhost:8000?", + ); + process.exit(1); } - preferredInferenceApi = await validateOpenAiLikeSelection( - "Local Ollama", + const validation = await validateOpenAiLikeSelection( + "Local vLLM", getLocalProviderValidationBaseUrl(provider), model, - null, - "Choose a different Ollama model or select Other." + credentialEnv, ); - if (!preferredInferenceApi) { - continue; + if ( + validation.retry === "selection" || + validation.retry === "back" || + validation.retry === "model" + ) { + continue selectionLoop; } - break; - } - break; - } else if (selected.key === "vllm") { - console.log(" ✓ Using existing vLLM on localhost:8000"); - provider = "vllm-local"; - credentialEnv = "OPENAI_API_KEY"; - endpointUrl = getLocalProviderBaseUrl(provider); - // Query vLLM for the actual model ID - const vllmModelsRaw = runCapture("curl -sf http://localhost:8000/v1/models 2>/dev/null", { ignoreError: true }); - try { - const vllmModels = JSON.parse(vllmModelsRaw); - if (vllmModels.data && vllmModels.data.length > 0) { - model = vllmModels.data[0].id; - if (!isSafeModelId(model)) { - console.error(` Detected model ID contains invalid characters: ${model}`); - process.exit(1); - } - console.log(` Detected model: ${model}`); - } else { - console.error(" Could not detect model from vLLM. Please specify manually."); - process.exit(1); + if (!validation.ok) { + continue selectionLoop; } - } catch { - console.error(" Could not query vLLM models endpoint. Is vLLM running on localhost:8000?"); - process.exit(1); - } - preferredInferenceApi = await validateOpenAiLikeSelection( - "Local vLLM", - getLocalProviderValidationBaseUrl(provider), - model, - credentialEnv - ); - if (!preferredInferenceApi) { - continue selectionLoop; + preferredInferenceApi = validation.api; + // Force chat completions — vLLM's /v1/responses endpoint does not + // run the --tool-call-parser, so tool calls arrive as raw text. + // See: https://github.com/NVIDIA/NemoClaw/issues/976 + if (preferredInferenceApi !== "openai-completions") { + console.log( + " ℹ Using chat completions API (tool-call-parser requires /v1/chat/completions)", + ); + } + preferredInferenceApi = "openai-completions"; + break; } - break; } } - } return { model, provider, endpointUrl, credentialEnv, preferredInferenceApi, nimContainer }; } -// ── Step 5: Inference provider ─────────────────────────────────── +// ── Step 4: Inference provider ─────────────────────────────────── -async function setupInference(sandboxName, model, provider, endpointUrl = null, credentialEnv = null) { +// eslint-disable-next-line complexity +async function setupInference( + sandboxName, + model, + provider, + endpointUrl = null, + credentialEnv = null, +) { step(4, 7, "Setting up inference provider"); runOpenshell(["gateway", "select", GATEWAY_NAME], { ignoreError: true }); - if (provider === "nvidia-prod" || provider === "nvidia-nim" || provider === "openai-api" || provider === "anthropic-prod" || provider === "compatible-anthropic-endpoint" || provider === "gemini-api" || provider === "compatible-endpoint") { - const config = provider === "nvidia-nim" - ? REMOTE_PROVIDER_CONFIG.build - : Object.values(REMOTE_PROVIDER_CONFIG).find((entry) => entry.providerName === provider); - const resolvedCredentialEnv = credentialEnv || (config && config.credentialEnv); - const resolvedEndpointUrl = endpointUrl || (config && config.endpointUrl); - const env = resolvedCredentialEnv ? { [resolvedCredentialEnv]: process.env[resolvedCredentialEnv] } : {}; - upsertProvider(provider, config.providerType, resolvedCredentialEnv, resolvedEndpointUrl, env); - const args = ["inference", "set"]; - if (config.skipVerify) { - args.push("--no-verify"); - } - args.push("--provider", provider, "--model", model); - runOpenshell(args); + if ( + provider === "nvidia-prod" || + provider === "nvidia-nim" || + provider === "openai-api" || + provider === "anthropic-prod" || + provider === "compatible-anthropic-endpoint" || + provider === "gemini-api" || + provider === "compatible-endpoint" + ) { + const config = + provider === "nvidia-nim" + ? REMOTE_PROVIDER_CONFIG.build + : Object.values(REMOTE_PROVIDER_CONFIG).find((entry) => entry.providerName === provider); + while (true) { + const resolvedCredentialEnv = credentialEnv || (config && config.credentialEnv); + const resolvedEndpointUrl = endpointUrl || (config && config.endpointUrl); + const credentialValue = hydrateCredentialEnv(resolvedCredentialEnv); + const env = + resolvedCredentialEnv && credentialValue + ? { [resolvedCredentialEnv]: credentialValue } + : {}; + const providerResult = upsertProvider( + provider, + config.providerType, + resolvedCredentialEnv, + resolvedEndpointUrl, + env, + ); + if (!providerResult.ok) { + console.error(` ${providerResult.message}`); + if (isNonInteractive()) { + process.exit(providerResult.status || 1); + } + const retry = await promptValidationRecovery( + config.label, + classifyApplyFailure(providerResult.message), + resolvedCredentialEnv, + config.helpUrl, + ); + if (retry === "credential" || retry === "retry") { + continue; + } + if (retry === "selection" || retry === "model") { + return { retry: "selection" }; + } + process.exit(providerResult.status || 1); + } + const args = ["inference", "set"]; + if (config.skipVerify) { + args.push("--no-verify"); + } + args.push("--provider", provider, "--model", model); + const applyResult = runOpenshell(args, { ignoreError: true }); + if (applyResult.status === 0) { + break; + } + const message = + compactText(`${applyResult.stderr || ""} ${applyResult.stdout || ""}`) || + `Failed to configure inference provider '${provider}'.`; + console.error(` ${message}`); + if (isNonInteractive()) { + process.exit(applyResult.status || 1); + } + const retry = await promptValidationRecovery( + config.label, + classifyApplyFailure(message), + resolvedCredentialEnv, + config.helpUrl, + ); + if (retry === "credential" || retry === "retry") { + continue; + } + if (retry === "selection" || retry === "model") { + return { retry: "selection" }; + } + process.exit(applyResult.status || 1); + } } else if (provider === "vllm-local") { const validation = validateLocalProvider(provider, runCapture); if (!validation.ok) { @@ -1940,9 +2954,13 @@ async function setupInference(sandboxName, model, provider, endpointUrl = null, process.exit(1); } const baseUrl = getLocalProviderBaseUrl(provider); - upsertProvider("vllm-local", "openai", "OPENAI_API_KEY", baseUrl, { + const providerResult = upsertProvider("vllm-local", "openai", "OPENAI_API_KEY", baseUrl, { OPENAI_API_KEY: "dummy", }); + if (!providerResult.ok) { + console.error(` ${providerResult.message}`); + process.exit(providerResult.status || 1); + } runOpenshell(["inference", "set", "--no-verify", "--provider", "vllm-local", "--model", model]); } else if (provider === "ollama-local") { const validation = validateLocalProvider(provider, runCapture); @@ -1952,10 +2970,22 @@ async function setupInference(sandboxName, model, provider, endpointUrl = null, process.exit(1); } const baseUrl = getLocalProviderBaseUrl(provider); - upsertProvider("ollama-local", "openai", "OPENAI_API_KEY", baseUrl, { + const providerResult = upsertProvider("ollama-local", "openai", "OPENAI_API_KEY", baseUrl, { OPENAI_API_KEY: "ollama", }); - runOpenshell(["inference", "set", "--no-verify", "--provider", "ollama-local", "--model", model]); + if (!providerResult.ok) { + console.error(` ${providerResult.message}`); + process.exit(providerResult.status || 1); + } + runOpenshell([ + "inference", + "set", + "--no-verify", + "--provider", + "ollama-local", + "--model", + model, + ]); console.log(` Priming Ollama model: ${model}`); run(getOllamaWarmupCommand(model), { ignoreError: true }); const probe = validateOllamaModel(model, runCapture); @@ -1968,6 +2998,7 @@ async function setupInference(sandboxName, model, provider, endpointUrl = null, verifyInferenceRoute(provider, model); registry.updateSandbox(sandboxName, { model, provider }); console.log(` ✓ Inference route set: ${provider} / ${model}`); + return { ok: true }; } // ── Step 6: OpenClaw ───────────────────────────────────────────── @@ -1986,10 +3017,10 @@ async function setupOpenclaw(sandboxName, model, provider) { try { run( `${openshellShellCommand(["sandbox", "connect", sandboxName])} < ${shellQuote(scriptFile)}`, - { stdio: ["ignore", "ignore", "inherit"] } + { stdio: ["ignore", "ignore", "inherit"] }, ); } finally { - fs.unlinkSync(scriptFile); + cleanupTempDir(scriptFile, "nemoclaw-sync"); } } @@ -1998,7 +3029,8 @@ async function setupOpenclaw(sandboxName, model, provider) { // ── Step 7: Policy presets ─────────────────────────────────────── -async function setupPolicies(sandboxName) { +// eslint-disable-next-line complexity +async function _setupPolicies(sandboxName) { step(7, 7, "Policy presets"); const suggestions = ["pypi", "npm"]; @@ -2081,13 +3113,15 @@ async function setupPolicies(sandboxName) { console.log(""); console.log(" Available policy presets:"); allPresets.forEach((p) => { - const marker = applied.includes(p.name) ? "●" : "○"; + const marker = applied.includes(p.name) || suggestions.includes(p.name) ? "●" : "○"; const suggested = suggestions.includes(p.name) ? " (suggested)" : ""; console.log(` ${marker} ${p.name} — ${p.description}${suggested}`); }); console.log(""); - const answer = await prompt(` Apply suggested presets (${suggestions.join(", ")})? [Y/n/list]: `); + const answer = await prompt( + ` Apply suggested presets (${suggestions.join(", ")})? [Y/n/list]: `, + ); if (answer.toLowerCase() === "n") { console.log(" Skipping policy presets."); @@ -2102,7 +3136,10 @@ async function setupPolicies(sandboxName) { if (answer.toLowerCase() === "list") { // Let user pick const picks = await prompt(" Enter preset names (comma-separated): "); - const selected = picks.split(",").map((s) => s.trim()).filter(Boolean); + const selected = picks + .split(",") + .map((s) => s.trim()) + .filter(Boolean); for (const name of selected) { try { policies.applyPreset(sandboxName, name); @@ -2135,8 +3172,223 @@ async function setupPolicies(sandboxName) { console.log(" ✓ Policies applied"); } +function arePolicyPresetsApplied(sandboxName, selectedPresets = []) { + if (!Array.isArray(selectedPresets) || selectedPresets.length === 0) return false; + const applied = new Set(policies.getAppliedPresets(sandboxName)); + return selectedPresets.every((preset) => applied.has(preset)); +} + +// eslint-disable-next-line complexity +async function setupPoliciesWithSelection(sandboxName, options = {}) { + const selectedPresets = Array.isArray(options.selectedPresets) ? options.selectedPresets : null; + const onSelection = typeof options.onSelection === "function" ? options.onSelection : null; + + step(7, 7, "Policy presets"); + + const suggestions = ["pypi", "npm"]; + if (getCredential("TELEGRAM_BOT_TOKEN")) suggestions.push("telegram"); + if (getCredential("SLACK_BOT_TOKEN") || process.env.SLACK_BOT_TOKEN) suggestions.push("slack"); + if (getCredential("DISCORD_BOT_TOKEN") || process.env.DISCORD_BOT_TOKEN) + suggestions.push("discord"); + + const allPresets = policies.listPresets(); + const applied = policies.getAppliedPresets(sandboxName); + let chosen = selectedPresets; + + if (chosen && chosen.length > 0) { + if (onSelection) onSelection(chosen); + if (!waitForSandboxReady(sandboxName)) { + console.error(` Sandbox '${sandboxName}' was not ready for policy application.`); + process.exit(1); + } + note(` [resume] Reapplying policy presets: ${chosen.join(", ")}`); + for (const name of chosen) { + if (applied.includes(name)) continue; + policies.applyPreset(sandboxName, name); + } + return chosen; + } + + if (isNonInteractive()) { + const policyMode = (process.env.NEMOCLAW_POLICY_MODE || "suggested").trim().toLowerCase(); + chosen = suggestions; + + if (policyMode === "skip" || policyMode === "none" || policyMode === "no") { + note(" [non-interactive] Skipping policy presets."); + return []; + } + + if (policyMode === "custom" || policyMode === "list") { + chosen = parsePolicyPresetEnv(process.env.NEMOCLAW_POLICY_PRESETS); + if (chosen.length === 0) { + console.error(" NEMOCLAW_POLICY_PRESETS is required when NEMOCLAW_POLICY_MODE=custom."); + process.exit(1); + } + } else if (policyMode === "suggested" || policyMode === "default" || policyMode === "auto") { + const envPresets = parsePolicyPresetEnv(process.env.NEMOCLAW_POLICY_PRESETS); + if (envPresets.length > 0) { + chosen = envPresets; + } + } else { + console.error(` Unsupported NEMOCLAW_POLICY_MODE: ${policyMode}`); + console.error(" Valid values: suggested, custom, skip"); + process.exit(1); + } + + const knownPresets = new Set(allPresets.map((p) => p.name)); + const invalidPresets = chosen.filter((name) => !knownPresets.has(name)); + if (invalidPresets.length > 0) { + console.error(` Unknown policy preset(s): ${invalidPresets.join(", ")}`); + process.exit(1); + } + + if (onSelection) onSelection(chosen); + if (!waitForSandboxReady(sandboxName)) { + console.error(` Sandbox '${sandboxName}' was not ready for policy application.`); + process.exit(1); + } + note(` [non-interactive] Applying policy presets: ${chosen.join(", ")}`); + for (const name of chosen) { + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + policies.applyPreset(sandboxName, name); + break; + } catch (err) { + const message = err && err.message ? err.message : String(err); + if (message.includes("Unimplemented")) { + console.error(" OpenShell policy updates are not supported by this gateway build."); + console.error(" This is a known issue tracked in NemoClaw #536."); + throw err; + } + if (!message.includes("sandbox not found") || attempt === 2) { + throw err; + } + sleep(2); + } + } + } + return chosen; + } + + console.log(""); + console.log(" Available policy presets:"); + allPresets.forEach((p) => { + const marker = applied.includes(p.name) ? "●" : "○"; + const suggested = suggestions.includes(p.name) ? " (suggested)" : ""; + console.log(` ${marker} ${p.name} — ${p.description}${suggested}`); + }); + console.log(""); + + const answer = await prompt( + ` Apply suggested presets (${suggestions.join(", ")})? [Y/n/list]: `, + ); + + if (answer.toLowerCase() === "n") { + console.log(" Skipping policy presets."); + return []; + } + + let interactiveChoice = suggestions; + if (answer.toLowerCase() === "list") { + const custom = await prompt(" Enter preset names (comma-separated): "); + interactiveChoice = parsePolicyPresetEnv(custom); + } + + const knownPresets = new Set(allPresets.map((p) => p.name)); + let invalidPresets = interactiveChoice.filter((name) => !knownPresets.has(name)); + while (invalidPresets.length > 0) { + console.error(` Unknown policy preset(s): ${invalidPresets.join(", ")}`); + console.log(" Available presets:"); + for (const p of allPresets) { + console.log(` - ${p.name}`); + } + const retry = await prompt(" Enter preset names (comma-separated), or leave empty to skip: "); + if (!retry.trim()) { + console.log(" Skipping policy presets."); + return []; + } + interactiveChoice = parsePolicyPresetEnv(retry); + invalidPresets = interactiveChoice.filter((name) => !knownPresets.has(name)); + } + + if (onSelection) onSelection(interactiveChoice); + if (!waitForSandboxReady(sandboxName)) { + console.error(` Sandbox '${sandboxName}' was not ready for policy application.`); + process.exit(1); + } + + for (const name of interactiveChoice) { + policies.applyPreset(sandboxName, name); + } + return interactiveChoice; +} + // ── Dashboard ──────────────────────────────────────────────────── +const CONTROL_UI_PORT = 18789; + +// Dashboard helpers — delegated to src/lib/dashboard.ts +// isLoopbackHostname — see urlUtils import above +const { resolveDashboardForwardTarget, buildControlUiUrls } = dashboard; + +function ensureDashboardForward(sandboxName, chatUiUrl = `http://127.0.0.1:${CONTROL_UI_PORT}`) { + const forwardTarget = resolveDashboardForwardTarget(chatUiUrl); + runOpenshell(["forward", "stop", String(CONTROL_UI_PORT)], { ignoreError: true }); + // Use stdio "ignore" to prevent spawnSync from waiting on inherited pipe fds. + // The --background flag forks a child that inherits stdout/stderr; if those are + // pipes, spawnSync blocks until the background process exits (never). + runOpenshell(["forward", "start", "--background", forwardTarget, sandboxName], { + ignoreError: true, + stdio: ["ignore", "ignore", "ignore"], + }); +} + +function findOpenclawJsonPath(dir) { + if (!fs.existsSync(dir)) return null; + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const e of entries) { + const p = path.join(dir, e.name); + if (e.isDirectory()) { + const found = findOpenclawJsonPath(p); + if (found) return found; + } else if (e.name === "openclaw.json") { + return p; + } + } + return null; +} + +/** + * Pull gateway.auth.token from the sandbox image via openshell sandbox download + * so onboard can print copy-paste Control UI URLs with #token= (same idea as nemoclaw-start.sh). + */ +function fetchGatewayAuthTokenFromSandbox(sandboxName) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-token-")); + try { + const destDir = `${tmpDir}${path.sep}`; + const result = runOpenshell( + ["sandbox", "download", sandboxName, "/sandbox/.openclaw/openclaw.json", destDir], + { ignoreError: true, stdio: ["ignore", "ignore", "ignore"] }, + ); + if (result.status !== 0) return null; + const jsonPath = findOpenclawJsonPath(tmpDir); + if (!jsonPath) return null; + const cfg = JSON.parse(fs.readFileSync(jsonPath, "utf-8")); + const token = cfg && cfg.gateway && cfg.gateway.auth && cfg.gateway.auth.token; + return typeof token === "string" && token.length > 0 ? token : null; + } catch { + return null; + } finally { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } + } +} + +// buildControlUiUrls — see dashboard import above + function printDashboard(sandboxName, model, provider, nimContainer = null) { const nimStat = nimContainer ? nim.nimStatusByName(nimContainer) : nim.nimStatus(sandboxName); const nimLabel = nimStat.running ? "running" : "not running"; @@ -2145,12 +3397,15 @@ function printDashboard(sandboxName, model, provider, nimContainer = null) { if (provider === "nvidia-prod" || provider === "nvidia-nim") providerLabel = "NVIDIA Endpoints"; else if (provider === "openai-api") providerLabel = "OpenAI"; else if (provider === "anthropic-prod") providerLabel = "Anthropic"; - else if (provider === "compatible-anthropic-endpoint") providerLabel = "Other Anthropic-compatible endpoint"; + else if (provider === "compatible-anthropic-endpoint") + providerLabel = "Other Anthropic-compatible endpoint"; else if (provider === "gemini-api") providerLabel = "Google Gemini"; else if (provider === "compatible-endpoint") providerLabel = "Other OpenAI-compatible endpoint"; else if (provider === "vllm-local") providerLabel = "Local vLLM"; else if (provider === "ollama-local") providerLabel = "Local Ollama"; + const token = fetchGatewayAuthTokenFromSandbox(sandboxName); + console.log(""); console.log(` ${"─".repeat(50)}`); // console.log(` Dashboard http://localhost:18789/`); @@ -2158,58 +3413,413 @@ function printDashboard(sandboxName, model, provider, nimContainer = null) { console.log(` Model ${model} (${providerLabel})`); console.log(` NIM ${nimLabel}`); console.log(` ${"─".repeat(50)}`); - console.log(` Next:`); console.log(` Run: nemoclaw ${sandboxName} connect`); console.log(` Status: nemoclaw ${sandboxName} status`); console.log(` Logs: nemoclaw ${sandboxName} logs --follow`); + console.log(""); + if (token) { + console.log(" OpenClaw UI (tokenized URL; treat it like a password)"); + console.log(` Port ${CONTROL_UI_PORT} must be forwarded before opening this URL.`); + for (const url of buildControlUiUrls(token)) { + console.log(` ${url}`); + } + } else { + note(" Could not read gateway token from the sandbox (download failed)."); + console.log(" OpenClaw UI"); + console.log(` Port ${CONTROL_UI_PORT} must be forwarded before opening this URL.`); + for (const url of buildControlUiUrls()) { + console.log(` ${url}`); + } + console.log( + ` Token: nemoclaw ${sandboxName} connect → jq -r '.gateway.auth.token' /sandbox/.openclaw/openclaw.json`, + ); + console.log( + ` append #token= to the URL, or see /tmp/gateway.log inside the sandbox.`, + ); + } console.log(` ${"─".repeat(50)}`); console.log(""); } +function startRecordedStep(stepName, updates = {}) { + onboardSession.markStepStarted(stepName); + if (Object.keys(updates).length > 0) { + onboardSession.updateSession((session) => { + if (typeof updates.sandboxName === "string") session.sandboxName = updates.sandboxName; + if (typeof updates.provider === "string") session.provider = updates.provider; + if (typeof updates.model === "string") session.model = updates.model; + return session; + }); + } +} + +const ONBOARD_STEP_INDEX = { + preflight: { number: 1, title: "Preflight checks" }, + gateway: { number: 2, title: "Starting OpenShell gateway" }, + provider_selection: { number: 3, title: "Configuring inference (NIM)" }, + inference: { number: 4, title: "Setting up inference provider" }, + sandbox: { number: 5, title: "Creating sandbox" }, + openclaw: { number: 6, title: "Setting up OpenClaw inside sandbox" }, + policies: { number: 7, title: "Policy presets" }, +}; + +function skippedStepMessage(stepName, detail, reason = "resume") { + const stepInfo = ONBOARD_STEP_INDEX[stepName]; + if (stepInfo) { + step(stepInfo.number, 7, stepInfo.title); + } + const prefix = reason === "reuse" ? "[reuse]" : "[resume]"; + console.log(` ${prefix} Skipping ${stepName}${detail ? ` (${detail})` : ""}`); +} + // ── Main ───────────────────────────────────────────────────────── +// eslint-disable-next-line complexity async function onboard(opts = {}) { NON_INTERACTIVE = opts.nonInteractive || process.env.NEMOCLAW_NON_INTERACTIVE === "1"; delete process.env.OPENSHELL_GATEWAY; + const resume = opts.resume === true; + const noticeAccepted = await ensureUsageNoticeConsent({ + nonInteractive: isNonInteractive(), + acceptedByFlag: opts.acceptThirdPartySoftware === true, + writeLine: console.error, + }); + if (!noticeAccepted) { + process.exit(1); + } + const lockResult = onboardSession.acquireOnboardLock( + `nemoclaw onboard${resume ? " --resume" : ""}${isNonInteractive() ? " --non-interactive" : ""}`, + ); + if (!lockResult.acquired) { + console.error(" Another NemoClaw onboarding run is already in progress."); + if (lockResult.holderPid) { + console.error(` Lock holder PID: ${lockResult.holderPid}`); + } + if (lockResult.holderStartedAt) { + console.error(` Started: ${lockResult.holderStartedAt}`); + } + console.error(" Wait for it to finish, or remove the stale lock if the previous run crashed:"); + console.error(` rm -f "${lockResult.lockFile}"`); + process.exit(1); + } - console.log(""); - console.log(" NemoClaw Onboarding"); - if (isNonInteractive()) note(" (non-interactive mode)"); - console.log(" ==================="); - - const gpu = await preflight(); - const { model, provider, endpointUrl, credentialEnv, preferredInferenceApi, nimContainer } = await setupNim(gpu); - process.env.NEMOCLAW_OPENSHELL_BIN = getOpenshellBinary(); - await startGateway(gpu); - await setupInference(GATEWAY_NAME, model, provider, endpointUrl, credentialEnv); - // The key is now stored in openshell's provider config. Clear it from our - // process environment so new child processes don't inherit it. Note: this - // does NOT clear /proc/pid/environ (kernel snapshot is immutable after exec), - // but it prevents run()'s { ...process.env } from propagating the key. - delete process.env.NVIDIA_API_KEY; - const sandboxName = await createSandbox(gpu, model, provider, preferredInferenceApi); - if (nimContainer) { - registry.updateSandbox(sandboxName, { nimContainer }); - } - await setupOpenclaw(sandboxName, model, provider); - await setupPolicies(sandboxName); - printDashboard(sandboxName, model, provider, nimContainer); + let lockReleased = false; + const releaseOnboardLock = () => { + if (lockReleased) return; + lockReleased = true; + onboardSession.releaseOnboardLock(); + }; + process.once("exit", releaseOnboardLock); + + try { + let session; + if (resume) { + session = onboardSession.loadSession(); + if (!session || session.resumable === false) { + console.error(" No resumable onboarding session was found."); + console.error(" Run: nemoclaw onboard"); + process.exit(1); + } + const resumeConflicts = getResumeConfigConflicts(session, { + nonInteractive: isNonInteractive(), + }); + if (resumeConflicts.length > 0) { + for (const conflict of resumeConflicts) { + if (conflict.field === "sandbox") { + console.error( + ` Resumable state belongs to sandbox '${conflict.recorded}', not '${conflict.requested}'.`, + ); + } else { + console.error( + ` Resumable state recorded ${conflict.field} '${conflict.recorded}', not '${conflict.requested}'.`, + ); + } + } + console.error(" Run: nemoclaw onboard # start a fresh onboarding session"); + console.error(" Or rerun with the original settings to continue that session."); + process.exit(1); + } + onboardSession.updateSession((current) => { + current.mode = isNonInteractive() ? "non-interactive" : "interactive"; + current.failure = null; + current.status = "in_progress"; + return current; + }); + session = onboardSession.loadSession(); + } else { + session = onboardSession.saveSession( + onboardSession.createSession({ + mode: isNonInteractive() ? "non-interactive" : "interactive", + metadata: { gatewayName: "nemoclaw" }, + }), + ); + } + + let completed = false; + process.once("exit", (code) => { + if (!completed && code !== 0) { + const current = onboardSession.loadSession(); + const failedStep = current?.lastStepStarted; + if (failedStep) { + onboardSession.markStepFailed(failedStep, "Onboarding exited before the step completed."); + } + } + }); + + console.log(""); + console.log(" NemoClaw Onboarding"); + if (isNonInteractive()) note(" (non-interactive mode)"); + if (resume) note(" (resume mode)"); + console.log(" ==================="); + + let gpu; + const resumePreflight = resume && session?.steps?.preflight?.status === "complete"; + if (resumePreflight) { + skippedStepMessage("preflight", "cached"); + gpu = nim.detectGpu(); + } else { + startRecordedStep("preflight"); + gpu = await preflight(); + onboardSession.markStepComplete("preflight"); + } + + const gatewayStatus = runCaptureOpenshell(["status"], { ignoreError: true }); + const gatewayInfo = runCaptureOpenshell(["gateway", "info", "-g", GATEWAY_NAME], { + ignoreError: true, + }); + const activeGatewayInfo = runCaptureOpenshell(["gateway", "info"], { ignoreError: true }); + const gatewayReuseState = getGatewayReuseState(gatewayStatus, gatewayInfo, activeGatewayInfo); + const canReuseHealthyGateway = gatewayReuseState === "healthy"; + const resumeGateway = + resume && session?.steps?.gateway?.status === "complete" && canReuseHealthyGateway; + if (resumeGateway) { + skippedStepMessage("gateway", "running"); + } else if (!resume && canReuseHealthyGateway) { + skippedStepMessage("gateway", "running", "reuse"); + note(" Reusing healthy NemoClaw gateway."); + } else { + if (resume && session?.steps?.gateway?.status === "complete") { + if (gatewayReuseState === "active-unnamed") { + note(" [resume] Gateway is active but named metadata is missing; recreating it safely."); + } else if (gatewayReuseState === "foreign-active") { + note(" [resume] A different OpenShell gateway is active; NemoClaw will not reuse it."); + } else if (gatewayReuseState === "stale") { + note(" [resume] Recorded gateway is unhealthy; recreating it."); + } else { + note(" [resume] Recorded gateway state is unavailable; recreating it."); + } + } + startRecordedStep("gateway"); + await startGateway(gpu); + onboardSession.markStepComplete("gateway"); + } + + let sandboxName = session?.sandboxName || null; + let model = session?.model || null; + let provider = session?.provider || null; + let endpointUrl = session?.endpointUrl || null; + let credentialEnv = session?.credentialEnv || null; + let preferredInferenceApi = session?.preferredInferenceApi || null; + let nimContainer = session?.nimContainer || null; + let forceProviderSelection = false; + while (true) { + const resumeProviderSelection = + !forceProviderSelection && + resume && + session?.steps?.provider_selection?.status === "complete" && + typeof provider === "string" && + typeof model === "string"; + if (resumeProviderSelection) { + skippedStepMessage("provider_selection", `${provider} / ${model}`); + hydrateCredentialEnv(credentialEnv); + } else { + startRecordedStep("provider_selection", { sandboxName }); + const selection = await setupNim(gpu); + model = selection.model; + provider = selection.provider; + endpointUrl = selection.endpointUrl; + credentialEnv = selection.credentialEnv; + preferredInferenceApi = selection.preferredInferenceApi; + nimContainer = selection.nimContainer; + onboardSession.markStepComplete("provider_selection", { + sandboxName, + provider, + model, + endpointUrl, + credentialEnv, + preferredInferenceApi, + nimContainer, + }); + } + + process.env.NEMOCLAW_OPENSHELL_BIN = getOpenshellBinary(); + const resumeInference = + !forceProviderSelection && + resume && + typeof provider === "string" && + typeof model === "string" && + isInferenceRouteReady(provider, model); + if (resumeInference) { + skippedStepMessage("inference", `${provider} / ${model}`); + if (nimContainer) { + registry.updateSandbox(sandboxName, { nimContainer }); + } + onboardSession.markStepComplete("inference", { + sandboxName, + provider, + model, + nimContainer, + }); + break; + } + + startRecordedStep("inference", { sandboxName, provider, model }); + const inferenceResult = await setupInference( + GATEWAY_NAME, + model, + provider, + endpointUrl, + credentialEnv, + ); + delete process.env.NVIDIA_API_KEY; + if (inferenceResult?.retry === "selection") { + forceProviderSelection = true; + continue; + } + if (nimContainer) { + registry.updateSandbox(sandboxName, { nimContainer }); + } + onboardSession.markStepComplete("inference", { sandboxName, provider, model, nimContainer }); + break; + } + + const sandboxReuseState = getSandboxReuseState(sandboxName); + const resumeSandbox = + resume && session?.steps?.sandbox?.status === "complete" && sandboxReuseState === "ready"; + if (resumeSandbox) { + skippedStepMessage("sandbox", sandboxName); + } else { + if (resume && session?.steps?.sandbox?.status === "complete") { + if (sandboxReuseState === "not_ready") { + note( + ` [resume] Recorded sandbox '${sandboxName}' exists but is not ready; recreating it.`, + ); + repairRecordedSandbox(sandboxName); + } else { + note(" [resume] Recorded sandbox state is unavailable; recreating it."); + if (sandboxName) { + registry.removeSandbox(sandboxName); + } + } + } + startRecordedStep("sandbox", { sandboxName, provider, model }); + sandboxName = await createSandbox(gpu, model, provider, preferredInferenceApi, sandboxName); + onboardSession.markStepComplete("sandbox", { sandboxName, provider, model, nimContainer }); + } + + const resumeOpenclaw = resume && sandboxName && isOpenclawReady(sandboxName); + if (resumeOpenclaw) { + skippedStepMessage("openclaw", sandboxName); + onboardSession.markStepComplete("openclaw", { sandboxName, provider, model }); + } else { + startRecordedStep("openclaw", { sandboxName, provider, model }); + await setupOpenclaw(sandboxName, model, provider); + onboardSession.markStepComplete("openclaw", { sandboxName, provider, model }); + } + + const recordedPolicyPresets = Array.isArray(session?.policyPresets) + ? session.policyPresets + : null; + const resumePolicies = + resume && sandboxName && arePolicyPresetsApplied(sandboxName, recordedPolicyPresets || []); + if (resumePolicies) { + skippedStepMessage("policies", (recordedPolicyPresets || []).join(", ")); + onboardSession.markStepComplete("policies", { + sandboxName, + provider, + model, + policyPresets: recordedPolicyPresets || [], + }); + } else { + startRecordedStep("policies", { + sandboxName, + provider, + model, + policyPresets: recordedPolicyPresets || [], + }); + const appliedPolicyPresets = await setupPoliciesWithSelection(sandboxName, { + selectedPresets: + resume && + session?.steps?.policies?.status !== "complete" && + Array.isArray(recordedPolicyPresets) && + recordedPolicyPresets.length > 0 + ? recordedPolicyPresets + : null, + onSelection: (policyPresets) => { + onboardSession.updateSession((current) => { + current.policyPresets = policyPresets; + return current; + }); + }, + }); + onboardSession.markStepComplete("policies", { + sandboxName, + provider, + model, + policyPresets: appliedPolicyPresets, + }); + } + + onboardSession.completeSession({ sandboxName, provider, model }); + completed = true; + printDashboard(sandboxName, model, provider, nimContainer); + } finally { + releaseOnboardLock(); + } } module.exports = { buildSandboxConfigSyncScript, - getFutureShellPathHint, + copyBuildContextDir, + classifySandboxCreateFailure, createSandbox, + getFutureShellPathHint, + getGatewayStartEnv, + getGatewayReuseState, getSandboxInferenceConfig, getInstalledOpenshellVersion, + getRequestedModelHint, + getRequestedProviderHint, getStableGatewayImageRef, + getResumeConfigConflicts, + isGatewayHealthy, hasStaleGateway, + getRequestedSandboxNameHint, + getResumeSandboxConflict, + getSandboxReuseState, + getSandboxStateFromOutputs, + getPortConflictServiceHints, + classifyValidationFailure, isSandboxReady, + isLoopbackHostname, + normalizeProviderBaseUrl, onboard, + onboardSession, + printSandboxCreateRecoveryHints, pruneStaleSandboxEntry, + repairRecordedSandbox, + recoverGatewayRuntime, + resolveDashboardForwardTarget, + startGatewayForRecovery, runCaptureOpenshell, setupInference, setupNim, + isInferenceRouteReady, + isOpenclawReady, + arePolicyPresetsApplied, + setupPoliciesWithSelection, + hydrateCredentialEnv, + shouldIncludeBuildContextPath, writeSandboxConfigSyncFile, patchStagedDockerfile, }; diff --git a/bin/lib/platform.js b/bin/lib/platform.js index 67c31a3f3..1a7b9facf 100644 --- a/bin/lib/platform.js +++ b/bin/lib/platform.js @@ -35,8 +35,13 @@ function isUnsupportedMacosRuntime(runtime, opts = {}) { return platform === "darwin" && runtime === "podman"; } -function shouldPatchCoredns(runtime) { - return runtime === "colima"; +function shouldPatchCoredns(runtime, opts = {}) { + // k3s CoreDNS defaults to a loopback DNS that pods can't reach. + // Patch it to use a real upstream on most Docker-based runtimes. + // On WSL2, the host DNS is not routable from k3s pods - skip the + // patch and let setup-dns-proxy.sh handle resolution instead. + if (isWsl(opts)) return false; + return runtime !== "unknown"; } function getColimaDockerSocketCandidates(opts = {}) { diff --git a/bin/lib/policies.js b/bin/lib/policies.js index 75704a9a0..6a9accc4f 100644 --- a/bin/lib/policies.js +++ b/bin/lib/policies.js @@ -6,11 +6,12 @@ const fs = require("fs"); const path = require("path"); const os = require("os"); +const readline = require("readline"); +const YAML = require("yaml"); const { ROOT, run, runCapture, shellQuote } = require("./runner"); const registry = require("./registry"); const PRESETS_DIR = path.join(ROOT, "nemoclaw-blueprint", "policies", "presets"); - function getOpenshellCommand() { const binary = process.env.NEMOCLAW_OPENSHELL_BIN; if (!binary) return "openshell"; @@ -63,6 +64,7 @@ function getPresetEndpoints(content) { * `preset:` metadata header. */ function extractPresetEntries(presetContent) { + if (!presetContent) return null; const npMatch = presetContent.match(/^network_policies:\n([\s\S]*)$/m); if (!npMatch) return null; return npMatch[1].trimEnd(); @@ -75,8 +77,23 @@ function extractPresetEntries(presetContent) { function parseCurrentPolicy(raw) { if (!raw) return ""; const sep = raw.indexOf("---"); - if (sep === -1) return raw; - return raw.slice(sep + 3).trim(); + const candidate = (sep === -1 ? raw : raw.slice(sep + 3)).trim(); + if (!candidate) return ""; + if (/^(error|failed|invalid|warning|status)\b/i.test(candidate)) { + return ""; + } + if (!/^[a-z_][a-z0-9_]*\s*:/m.test(candidate)) { + return ""; + } + try { + const parsed = YAML.parse(candidate); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return ""; + } + } catch { + return ""; + } + return candidate; } /** @@ -93,6 +110,113 @@ function buildPolicyGetCommand(sandboxName) { return `${getOpenshellCommand()} policy get --full ${shellQuote(sandboxName)} 2>/dev/null`; } +/** + * Text-based fallback for merging preset entries into policy YAML. + * Used when preset entries cannot be parsed as structured YAML. + */ +function textBasedMerge(currentPolicy, presetEntries) { + if (!currentPolicy) { + return "version: 1\n\nnetwork_policies:\n" + presetEntries; + } + let merged; + if (/^network_policies\s*:/m.test(currentPolicy)) { + const lines = currentPolicy.split("\n"); + const result = []; + let inNp = false; + let inserted = false; + for (const line of lines) { + if (/^network_policies\s*:/.test(line)) { + inNp = true; + result.push(line); + continue; + } + if (inNp && /^\S.*:/.test(line) && !inserted) { + result.push(presetEntries); + inserted = true; + inNp = false; + } + result.push(line); + } + if (inNp && !inserted) result.push(presetEntries); + merged = result.join("\n"); + } else { + merged = currentPolicy.trimEnd() + "\n\nnetwork_policies:\n" + presetEntries; + } + if (!merged.trimStart().startsWith("version:")) merged = "version: 1\n\n" + merged; + return merged; +} + +/** + * Merge preset entries into existing policy YAML using structured YAML + * parsing. Replaces the previous text-based manipulation which could + * produce invalid YAML when indentation or ordering varied. + * + * Behavior: + * - Parses both current policy and preset entries as YAML + * - Merges network_policies by name (preset overrides on collision) + * - Preserves all non-network sections (filesystem_policy, process, etc.) + * - Ensures version: 1 exists + * + * @param {string} currentPolicy - Existing policy YAML (may be empty/versionless) + * @param {string} presetEntries - Indented network_policies entries from preset + * @returns {string} Merged YAML + */ +function mergePresetIntoPolicy(currentPolicy, presetEntries) { + const normalizedCurrentPolicy = parseCurrentPolicy(currentPolicy); + if (!presetEntries) { + return normalizedCurrentPolicy || "version: 1\n\nnetwork_policies:\n"; + } + + // Parse preset entries. They come as indented content under network_policies:, + // so we wrap them to make valid YAML for parsing. + let presetPolicies; + try { + const wrapped = "network_policies:\n" + presetEntries; + const parsed = YAML.parse(wrapped); + presetPolicies = parsed?.network_policies; + } catch { + presetPolicies = null; + } + + // If YAML parsing failed or entries are not a mergeable object, + // fall back to the text-based approach for backward compatibility. + if (!presetPolicies || typeof presetPolicies !== "object" || Array.isArray(presetPolicies)) { + return textBasedMerge(normalizedCurrentPolicy, presetEntries); + } + + if (!normalizedCurrentPolicy) { + return YAML.stringify({ version: 1, network_policies: presetPolicies }); + } + + // Parse the current policy as structured YAML + let current; + try { + current = YAML.parse(normalizedCurrentPolicy); + } catch { + return textBasedMerge(normalizedCurrentPolicy, presetEntries); + } + + if (!current || typeof current !== "object") current = {}; + + // Structured merge: preset entries override existing on name collision. + // Guard: network_policies may be an array in legacy policies — only + // object-merge when both sides are plain objects. + const existingNp = current.network_policies; + let mergedNp; + if (existingNp && typeof existingNp === "object" && !Array.isArray(existingNp)) { + mergedNp = { ...existingNp, ...presetPolicies }; + } else { + mergedNp = presetPolicies; + } + + const output = { version: current.version || 1 }; + for (const [key, val] of Object.entries(current)) { + if (key !== "version" && key !== "network_policies") output[key] = val; + } + output.network_policies = mergedNp; + + return YAML.stringify(output); +} function applyPreset(sandboxName, presetName) { // Guard against truncated sandbox names — WSL can truncate hyphenated // names during argument parsing, e.g. "my-assistant" → "m" @@ -100,7 +224,7 @@ function applyPreset(sandboxName, presetName) { if (!sandboxName || sandboxName.length > 63 || !isRfc1123Label) { throw new Error( `Invalid or truncated sandbox name: '${sandboxName}'. ` + - `Names must be 1-63 chars, lowercase alphanumeric, with optional internal hyphens.` + `Names must be 1-63 chars, lowercase alphanumeric, with optional internal hyphens.`, ); } @@ -119,63 +243,14 @@ function applyPreset(sandboxName, presetName) { // Get current policy YAML from sandbox let rawPolicy = ""; try { - rawPolicy = runCapture( - buildPolicyGetCommand(sandboxName), - { ignoreError: true } - ); - } catch { /* ignored */ } - - let currentPolicy = parseCurrentPolicy(rawPolicy); - - // Merge: inject preset entries under the existing network_policies key - let merged; - if (currentPolicy && currentPolicy.includes("network_policies:")) { - // Find the network_policies: line and append the new entries after it - // We need to insert before the next top-level key or end of file - const lines = currentPolicy.split("\n"); - const result = []; - let inNetworkPolicies = false; - let inserted = false; - - for (const line of lines) { - // Detect top-level keys (no leading whitespace, ends with colon) - const isTopLevel = /^\S.*:/.test(line); - - if (line.trim() === "network_policies:" || line.trim().startsWith("network_policies:")) { - inNetworkPolicies = true; - result.push(line); - continue; - } - - if (inNetworkPolicies && isTopLevel && !inserted) { - // We hit the next top-level key — insert preset entries before it - result.push(presetEntries); - inserted = true; - inNetworkPolicies = false; - } - - result.push(line); - } - - // If network_policies was the last section, append at end - if (inNetworkPolicies && !inserted) { - result.push(presetEntries); - } - - merged = result.join("\n"); - } else if (currentPolicy) { - // No network_policies section yet — append one - // Ensure version field exists - if (!currentPolicy.includes("version:")) { - currentPolicy = "version: 1\n" + currentPolicy; - } - merged = currentPolicy + "\n\nnetwork_policies:\n" + presetEntries; - } else { - // No current policy at all - merged = "version: 1\n\nnetwork_policies:\n" + presetEntries; + rawPolicy = runCapture(buildPolicyGetCommand(sandboxName), { ignoreError: true }); + } catch { + /* ignored */ } - // Write temp file and apply + const currentPolicy = parseCurrentPolicy(rawPolicy); + const merged = mergePresetIntoPolicy(currentPolicy, presetEntries); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-")); const tmpFile = path.join(tmpDir, "policy.yaml"); fs.writeFileSync(tmpFile, merged, { encoding: "utf-8", mode: 0o600 }); @@ -185,11 +260,18 @@ function applyPreset(sandboxName, presetName) { console.log(` Applied preset: ${presetName}`); } finally { - try { fs.unlinkSync(tmpFile); } catch { /* ignored */ } - try { fs.rmdirSync(tmpDir); } catch { /* ignored */ } + try { + fs.unlinkSync(tmpFile); + } catch { + /* ignored */ + } + try { + fs.rmdirSync(tmpDir); + } catch { + /* ignored */ + } } - // Update registry const sandbox = registry.getSandbox(sandboxName); if (sandbox) { const pols = sandbox.policies || []; @@ -207,6 +289,53 @@ function getAppliedPresets(sandboxName) { return sandbox ? sandbox.policies || [] : []; } +function selectFromList(items, { applied = [] } = {}) { + return new Promise((resolve) => { + process.stderr.write("\n Available presets:\n"); + items.forEach((item, i) => { + const marker = applied.includes(item.name) ? "●" : "○"; + const description = item.description ? ` — ${item.description}` : ""; + process.stderr.write(` ${i + 1}) ${marker} ${item.name}${description}\n`); + }); + process.stderr.write("\n ● applied, ○ not applied\n\n"); + const defaultIdx = items.findIndex((item) => !applied.includes(item.name)); + const defaultNum = defaultIdx >= 0 ? defaultIdx + 1 : null; + const question = defaultNum ? ` Choose preset [${defaultNum}]: ` : " Choose preset: "; + const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); + rl.question(question, (answer) => { + rl.close(); + if (!process.stdin.isTTY) { + if (typeof process.stdin.pause === "function") process.stdin.pause(); + if (typeof process.stdin.unref === "function") process.stdin.unref(); + } + const trimmed = answer.trim(); + const effectiveInput = trimmed || (defaultNum ? String(defaultNum) : ""); + if (!effectiveInput) { + resolve(null); + return; + } + if (!/^\d+$/.test(effectiveInput)) { + process.stderr.write("\n Invalid preset number.\n"); + resolve(null); + return; + } + const num = Number(effectiveInput); + const item = items[num - 1]; + if (!item) { + process.stderr.write("\n Invalid preset number.\n"); + resolve(null); + return; + } + if (applied.includes(item.name)) { + process.stderr.write(`\n Preset '${item.name}' is already applied.\n`); + resolve(null); + return; + } + resolve(item.name); + }); + }); +} + module.exports = { PRESETS_DIR, listPresets, @@ -216,6 +345,8 @@ module.exports = { parseCurrentPolicy, buildPolicySetCommand, buildPolicyGetCommand, + mergePresetIntoPolicy, applyPreset, getAppliedPresets, + selectFromList, }; diff --git a/bin/lib/preflight.js b/bin/lib/preflight.js index 007eb7c4f..69ade8086 100644 --- a/bin/lib/preflight.js +++ b/bin/lib/preflight.js @@ -1,108 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 // -// Preflight checks for NemoClaw onboarding. +// Thin re-export shim — the implementation lives in src/lib/preflight.ts, +// compiled to dist/lib/preflight.js. -const net = require("net"); -const { runCapture } = require("./runner"); - -async function probePortAvailability(port, opts = {}) { - if (typeof opts.probeImpl === "function") { - return opts.probeImpl(port); - } - - return new Promise((resolve) => { - const srv = net.createServer(); - srv.once("error", (/** @type {NodeJS.ErrnoException} */ err) => { - if (err.code === "EADDRINUSE") { - resolve({ - ok: false, - process: "unknown", - pid: null, - reason: `port ${port} is in use (EADDRINUSE)`, - }); - return; - } - - if (err.code === "EPERM" || err.code === "EACCES") { - resolve({ - ok: true, - warning: `port probe skipped: ${err.message}`, - }); - return; - } - - // Unexpected probe failure: do not report a false conflict. - resolve({ - ok: true, - warning: `port probe inconclusive: ${err.message}`, - }); - }); - srv.listen(port, "127.0.0.1", () => { - srv.close(() => resolve({ ok: true })); - }); - }); -} - -/** - * Check whether a TCP port is available for listening. - * - * Detection chain: - * 1. lsof (primary) — identifies the blocking process name + PID - * 2. Node.js net probe (fallback) — cross-platform, detects EADDRINUSE - * - * opts.lsofOutput — inject fake lsof output for testing (skips shell) - * opts.skipLsof — force the net-probe fallback path - * opts.probeImpl — async (port) => probe result for testing - * - * Returns: - * { ok: true } - * { ok: true, warning: string } - * { ok: false, process: string, pid: number|null, reason: string } - */ -async function checkPortAvailable(port, opts) { - const p = port || 18789; - const o = opts || {}; - - // ── lsof path ────────────────────────────────────────────────── - if (!o.skipLsof) { - let lsofOut; - if (typeof o.lsofOutput === "string") { - lsofOut = o.lsofOutput; - } else { - const hasLsof = runCapture("command -v lsof", { ignoreError: true }); - if (hasLsof) { - lsofOut = runCapture( - `lsof -i :${p} -sTCP:LISTEN -P -n 2>/dev/null`, - { ignoreError: true } - ); - } - } - - if (typeof lsofOut === "string") { - const lines = lsofOut.split("\n").filter((l) => l.trim()); - // Skip the header line (starts with COMMAND) - const dataLines = lines.filter((l) => !l.startsWith("COMMAND")); - if (dataLines.length > 0) { - // Parse first data line: COMMAND PID USER ... - const parts = dataLines[0].split(/\s+/); - const proc = parts[0] || "unknown"; - const pid = parseInt(parts[1], 10) || null; - return { - ok: false, - process: proc, - pid, - reason: `lsof reports ${proc} (PID ${pid}) listening on port ${p}`, - }; - } - // Empty lsof output is not authoritative — non-root users cannot - // see listeners owned by root (e.g., docker-proxy, leftover gateway). - // Fall through to the net probe which uses bind() at the kernel level. - } - } - - // ── net probe fallback ───────────────────────────────────────── - return probePortAvailability(p, o); -} - -module.exports = { checkPortAvailable, probePortAvailability }; +module.exports = require("../../dist/lib/preflight"); diff --git a/bin/lib/registry.js b/bin/lib/registry.js index a3e2692b1..885ea8c2e 100644 --- a/bin/lib/registry.js +++ b/bin/lib/registry.js @@ -7,20 +7,147 @@ const fs = require("fs"); const path = require("path"); const REGISTRY_FILE = path.join(process.env.HOME || "/tmp", ".nemoclaw", "sandboxes.json"); +const LOCK_DIR = REGISTRY_FILE + ".lock"; +const LOCK_OWNER = path.join(LOCK_DIR, "owner"); +const LOCK_STALE_MS = 10_000; +const LOCK_RETRY_MS = 100; +const LOCK_MAX_RETRIES = 120; + +/** + * Acquire an advisory lock using mkdir (atomic on POSIX). + * Writes an owner file with PID for stale-lock detection via process liveness. + */ +function acquireLock() { + fs.mkdirSync(path.dirname(REGISTRY_FILE), { recursive: true, mode: 0o700 }); + const sleepBuf = new Int32Array(new SharedArrayBuffer(4)); + for (let i = 0; i < LOCK_MAX_RETRIES; i++) { + try { + fs.mkdirSync(LOCK_DIR); + const ownerTmp = LOCK_OWNER + ".tmp." + process.pid; + try { + fs.writeFileSync(ownerTmp, String(process.pid), { mode: 0o600 }); + fs.renameSync(ownerTmp, LOCK_OWNER); + } catch (ownerErr) { + // Remove the directory we just created so it doesn't look like a stale lock + try { + fs.unlinkSync(ownerTmp); + } catch { + /* best effort */ + } + try { + fs.unlinkSync(LOCK_OWNER); + } catch { + /* best effort */ + } + try { + fs.rmdirSync(LOCK_DIR); + } catch { + /* best effort */ + } + throw ownerErr; + } + return; + } catch (err) { + if (err.code !== "EEXIST") throw err; + // Check if the lock owner is still alive + let ownerChecked = false; + try { + const ownerPid = parseInt(fs.readFileSync(LOCK_OWNER, "utf-8").trim(), 10); + if (Number.isFinite(ownerPid) && ownerPid > 0) { + ownerChecked = true; + let alive; + try { + process.kill(ownerPid, 0); + alive = true; + } catch (killErr) { + // EPERM means the process exists but we lack permission — still alive + alive = killErr.code === "EPERM"; + } + if (!alive) { + // Verify PID hasn't changed (TOCTOU guard) + const recheck = parseInt(fs.readFileSync(LOCK_OWNER, "utf-8").trim(), 10); + if (recheck === ownerPid) { + fs.rmSync(LOCK_DIR, { recursive: true, force: true }); + continue; + } + } + } + // Owner file empty/corrupt — another process may be mid-write + // (between mkdirSync and renameSync). Fall through to mtime check. + } catch { + // No owner file or lock dir released — fall through to mtime staleness + } + if (!ownerChecked) { + // No valid owner PID available — use mtime as fallback + try { + const stat = fs.statSync(LOCK_DIR); + if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) { + fs.rmSync(LOCK_DIR, { recursive: true, force: true }); + continue; + } + } catch { + // Lock was released between our check — retry immediately + continue; + } + } + Atomics.wait(sleepBuf, 0, 0, LOCK_RETRY_MS); + } + } + throw new Error(`Failed to acquire lock on ${REGISTRY_FILE} after ${LOCK_MAX_RETRIES} retries`); +} + +function releaseLock() { + try { + fs.unlinkSync(LOCK_OWNER); + } catch (err) { + if (err.code !== "ENOENT") throw err; + } + // rmSync handles leftover tmp files from crashed acquireLock attempts + try { + fs.rmSync(LOCK_DIR, { recursive: true, force: true }); + } catch (err) { + if (err.code !== "ENOENT") throw err; + } +} + +/** Run fn while holding the registry lock. Returns fn's return value. */ +function withLock(fn) { + acquireLock(); + try { + return fn(); + } finally { + releaseLock(); + } +} function load() { try { if (fs.existsSync(REGISTRY_FILE)) { return JSON.parse(fs.readFileSync(REGISTRY_FILE, "utf-8")); } - } catch { /* ignored */ } + } catch { + /* ignored */ + } return { sandboxes: {}, defaultSandbox: null }; } +/** Atomic write: tmp file + rename on the same filesystem. */ function save(data) { const dir = path.dirname(REGISTRY_FILE); fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); - fs.writeFileSync(REGISTRY_FILE, JSON.stringify(data, null, 2), { mode: 0o600 }); + const tmp = REGISTRY_FILE + ".tmp." + process.pid; + try { + fs.writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 }); + fs.renameSync(tmp, REGISTRY_FILE); + } catch (err) { + // Clean up partial temp file on failure + try { + fs.unlinkSync(tmp); + } catch { + /* best effort */ + } + throw err; + } } function getSandbox(name) { @@ -39,40 +166,49 @@ function getDefault() { } function registerSandbox(entry) { - const data = load(); - data.sandboxes[entry.name] = { - name: entry.name, - createdAt: entry.createdAt || new Date().toISOString(), - model: entry.model || null, - nimContainer: entry.nimContainer || null, - provider: entry.provider || null, - gpuEnabled: entry.gpuEnabled || false, - policies: entry.policies || [], - }; - if (!data.defaultSandbox) { - data.defaultSandbox = entry.name; - } - save(data); + return withLock(() => { + const data = load(); + data.sandboxes[entry.name] = { + name: entry.name, + createdAt: entry.createdAt || new Date().toISOString(), + model: entry.model || null, + nimContainer: entry.nimContainer || null, + provider: entry.provider || null, + gpuEnabled: entry.gpuEnabled || false, + policies: entry.policies || [], + }; + if (!data.defaultSandbox) { + data.defaultSandbox = entry.name; + } + save(data); + }); } function updateSandbox(name, updates) { - const data = load(); - if (!data.sandboxes[name]) return false; - Object.assign(data.sandboxes[name], updates); - save(data); - return true; + return withLock(() => { + const data = load(); + if (!data.sandboxes[name]) return false; + if (Object.prototype.hasOwnProperty.call(updates, "name") && updates.name !== name) { + return false; + } + Object.assign(data.sandboxes[name], updates); + save(data); + return true; + }); } function removeSandbox(name) { - const data = load(); - if (!data.sandboxes[name]) return false; - delete data.sandboxes[name]; - if (data.defaultSandbox === name) { - const remaining = Object.keys(data.sandboxes); - data.defaultSandbox = remaining.length > 0 ? remaining[0] : null; - } - save(data); - return true; + return withLock(() => { + const data = load(); + if (!data.sandboxes[name]) return false; + delete data.sandboxes[name]; + if (data.defaultSandbox === name) { + const remaining = Object.keys(data.sandboxes); + data.defaultSandbox = remaining.length > 0 ? remaining[0] : null; + } + save(data); + return true; + }); } function listSandboxes() { @@ -84,11 +220,13 @@ function listSandboxes() { } function setDefault(name) { - const data = load(); - if (!data.sandboxes[name]) return false; - data.defaultSandbox = name; - save(data); - return true; + return withLock(() => { + const data = load(); + if (!data.sandboxes[name]) return false; + data.defaultSandbox = name; + save(data); + return true; + }); } module.exports = { @@ -101,4 +239,8 @@ module.exports = { removeSandbox, listSandboxes, setDefault, + // Exported for testing + acquireLock, + releaseLock, + withLock, }; diff --git a/bin/lib/resolve-openshell.js b/bin/lib/resolve-openshell.js index 345e218e4..69c633689 100644 --- a/bin/lib/resolve-openshell.js +++ b/bin/lib/resolve-openshell.js @@ -1,49 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +// +// Thin re-export shim — the implementation lives in src/lib/resolve-openshell.ts, +// compiled to dist/lib/resolve-openshell.js. -const { execSync } = require("child_process"); -const fs = require("fs"); - -/** - * Resolve the openshell binary path. - * - * Checks `command -v` first (must return an absolute path to prevent alias - * injection), then falls back to common installation directories. - * - * @param {object} [opts] DI overrides for testing - * @param {string|null} [opts.commandVResult] Mock result (undefined = run real command) - * @param {function} [opts.checkExecutable] (path) => boolean - * @param {string} [opts.home] HOME override - * @returns {string|null} Absolute path to openshell, or null if not found - */ -function resolveOpenshell(opts = {}) { - const home = opts.home ?? process.env.HOME; - - // Step 1: command -v - if (opts.commandVResult === undefined) { - try { - const found = execSync("command -v openshell", { encoding: "utf-8" }).trim(); - if (found.startsWith("/")) return found; - } catch { /* ignored */ } - } else if (opts.commandVResult && opts.commandVResult.startsWith("/")) { - return opts.commandVResult; - } - - // Step 2: fallback candidates - const checkExecutable = opts.checkExecutable || ((p) => { - try { fs.accessSync(p, fs.constants.X_OK); return true; } catch { return false; } - }); - - const candidates = [ - ...(home && home.startsWith("/") ? [`${home}/.local/bin/openshell`] : []), - "/usr/local/bin/openshell", - "/usr/bin/openshell", - ]; - for (const p of candidates) { - if (checkExecutable(p)) return p; - } - - return null; -} - -module.exports = { resolveOpenshell }; +module.exports = require("../../dist/lib/resolve-openshell"); diff --git a/bin/lib/runner.js b/bin/lib/runner.js index d0ca4ceea..7ce41fc0c 100644 --- a/bin/lib/runner.js +++ b/bin/lib/runner.js @@ -13,36 +13,50 @@ if (dockerHost) { process.env.DOCKER_HOST = dockerHost.dockerHost; } +/** + * Run a shell command via bash, streaming stdout/stderr (redacted) to the terminal. + * Exits the process on failure unless opts.ignoreError is true. + */ function run(cmd, opts = {}) { - const stdio = opts.stdio ?? ["ignore", "inherit", "inherit"]; + const stdio = opts.stdio ?? ["ignore", "pipe", "pipe"]; const result = spawnSync("bash", ["-c", cmd], { ...opts, stdio, cwd: ROOT, env: { ...process.env, ...opts.env }, }); + writeRedactedResult(result, stdio); if (result.status !== 0 && !opts.ignoreError) { - console.error(` Command failed (exit ${result.status}): ${cmd.slice(0, 80)}`); + console.error(` Command failed (exit ${result.status}): ${redact(cmd).slice(0, 80)}`); process.exit(result.status || 1); } return result; } +/** + * Run a shell command interactively (stdin inherited) while capturing and redacting stdout/stderr. + * Exits the process on failure unless opts.ignoreError is true. + */ function runInteractive(cmd, opts = {}) { - const stdio = opts.stdio ?? "inherit"; + const stdio = opts.stdio ?? ["inherit", "pipe", "pipe"]; const result = spawnSync("bash", ["-c", cmd], { ...opts, stdio, cwd: ROOT, env: { ...process.env, ...opts.env }, }); + writeRedactedResult(result, stdio); if (result.status !== 0 && !opts.ignoreError) { - console.error(` Command failed (exit ${result.status}): ${cmd.slice(0, 80)}`); + console.error(` Command failed (exit ${result.status}): ${redact(cmd).slice(0, 80)}`); process.exit(result.status || 1); } return result; } +/** + * Run a shell command and return its stdout as a trimmed string. + * Throws a redacted error on failure, or returns '' when opts.ignoreError is true. + */ function runCapture(cmd, opts = {}) { try { return execSync(cmd, { @@ -54,7 +68,98 @@ function runCapture(cmd, opts = {}) { }).trim(); } catch (err) { if (opts.ignoreError) return ""; - throw err; + throw redactError(err); + } +} + +/** + * Redact known secret patterns from a string to prevent accidental leaks + * in CLI log and error output. Covers NVIDIA API keys, bearer tokens, + * generic API key assignments, and base64-style long tokens. + */ +const SECRET_PATTERNS = [ + /nvapi-[A-Za-z0-9_-]{10,}/g, + /nvcf-[A-Za-z0-9_-]{10,}/g, + /ghp_[A-Za-z0-9_-]{10,}/g, + /(?<=Bearer\s+)[A-Za-z0-9_.+/=-]{10,}/gi, + /(?<=(?:_KEY|API_KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL)[=: ]['"]?)[A-Za-z0-9_.+/=-]{10,}/gi, +]; + +/** + * Partially redact a matched secret string: keep the first 4 chars and replace + * the rest with asterisks (capped at 20 asterisks). + */ +function redactMatch(match) { + return match.slice(0, 4) + "*".repeat(Math.min(match.length - 4, 20)); +} + +/** + * Redact credentials from a URL string: clears url.password and blanks + * known auth-style query params (auth, sig, signature, token, access_token). + * Returns the original value unchanged if it cannot be parsed as a URL. + */ +function redactUrl(value) { + if (typeof value !== "string" || value.length === 0) return value; + try { + const url = new URL(value); + if (url.password) { + url.password = "****"; + } + for (const key of [...url.searchParams.keys()]) { + if (/(^|[-_])(?:signature|sig|token|auth|access_token)$/i.test(key)) { + url.searchParams.set(key, "****"); + } + } + return url.toString(); + } catch { + return value; + } +} + +/** + * Redact known secret patterns and authenticated URLs from a string. + * Non-string values are returned unchanged. + */ +function redact(str) { + if (typeof str !== "string") return str; + let out = str.replace(/https?:\/\/[^\s'"]+/g, redactUrl); + for (const pat of SECRET_PATTERNS) { + out = out.replace(pat, redactMatch); + } + return out; +} + +/** + * Redact sensitive fields on an error object before surfacing it to callers. + * NOTE: this mutates the original error instance in place. + */ +function redactError(err) { + if (!err || typeof err !== "object") return err; + const originalMessage = typeof err.message === "string" ? err.message : null; + if (typeof err.message === "string") err.message = redact(err.message); + if (typeof err.cmd === "string") err.cmd = redact(err.cmd); + if (typeof err.stdout === "string") err.stdout = redact(err.stdout); + if (typeof err.stderr === "string") err.stderr = redact(err.stderr); + if (Array.isArray(err.output)) { + err.output = err.output.map((value) => (typeof value === "string" ? redact(value) : value)); + } + if (originalMessage && typeof err.stack === "string") { + err.stack = err.stack.replaceAll(originalMessage, err.message); + } + return err; +} + +/** + * Write redacted stdout/stderr from a spawnSync result to the parent process streams. + * No-op when stdio is 'inherit' or not an array. + */ +function writeRedactedResult(result, stdio) { + if (!result || stdio === "inherit" || !Array.isArray(stdio)) return; + if (stdio[1] === "pipe" && result.stdout) { + process.stdout.write(redact(result.stdout.toString())); + } + if (stdio[2] === "pipe" && result.stderr) { + process.stderr.write(redact(result.stderr.toString())); } } @@ -79,10 +184,19 @@ function validateName(name, label = "name") { } if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(name)) { throw new Error( - `Invalid ${label}: '${name}'. Must be lowercase alphanumeric with optional internal hyphens.` + `Invalid ${label}: '${name}'. Must be lowercase alphanumeric with optional internal hyphens.`, ); } return name; } -module.exports = { ROOT, SCRIPTS, run, runCapture, runInteractive, shellQuote, validateName }; +module.exports = { + ROOT, + SCRIPTS, + redact, + run, + runCapture, + runInteractive, + shellQuote, + validateName, +}; diff --git a/bin/lib/runtime-recovery.js b/bin/lib/runtime-recovery.js new file mode 100644 index 000000000..0f0346c7d --- /dev/null +++ b/bin/lib/runtime-recovery.js @@ -0,0 +1,7 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Thin re-export shim — the implementation lives in src/lib/runtime-recovery.ts, +// compiled to dist/lib/runtime-recovery.js. + +module.exports = require("../../dist/lib/runtime-recovery"); diff --git a/bin/lib/services.js b/bin/lib/services.js new file mode 100644 index 000000000..3defe2e40 --- /dev/null +++ b/bin/lib/services.js @@ -0,0 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Thin CJS shim — implementation lives in src/lib/services.ts +module.exports = require("../../dist/lib/services"); diff --git a/bin/lib/usage-notice.js b/bin/lib/usage-notice.js new file mode 100644 index 000000000..6980825a4 --- /dev/null +++ b/bin/lib/usage-notice.js @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Thin re-export shim — the implementation lives in src/lib/usage-notice.ts, +// compiled to dist/lib/usage-notice.js. +const usageNotice = require("../../dist/lib/usage-notice"); + +if (require.main === module) { + usageNotice.cli().catch((error) => { + console.error(error?.message || String(error)); + process.exit(1); + }); +} + +module.exports = usageNotice; diff --git a/bin/lib/usage-notice.json b/bin/lib/usage-notice.json new file mode 100644 index 000000000..782ddc400 --- /dev/null +++ b/bin/lib/usage-notice.json @@ -0,0 +1,32 @@ +{ + "version": "2026-04-01b", + "title": "Third-Party Software Notice - NemoClaw Installer", + "referenceUrl": "https://docs.openclaw.ai/gateway/security", + "body": [ + "NemoClaw is licensed under Apache 2.0 and automatically", + "retrieves, accesses or interacts with third-party software", + "and materials, including by deploying OpenClaw in an", + "OpenShell sandbox. Those retrieved materials are not", + "distributed with this software and are governed solely", + "by separate terms, conditions and licenses.", + "", + "You are solely responsible for finding, reviewing and", + "complying with all applicable terms, conditions, and", + "licenses, and for verifying the security, integrity and", + "suitability of any retrieved materials for your specific", + "use case.", + "", + "This software is provided \"AS IS\", without warranty of", + "any kind. The author makes no representations or", + "warranties regarding any third-party software, and", + "assumes no liability for any losses, damages, liabilities", + "or legal consequences from your use or inability to use", + "this software or any retrieved materials. Use this", + "software and the retrieved materials at your own risk.", + "", + "OpenClaw security guidance", + "https://docs.openclaw.ai/gateway/security" + ], + "links": [], + "interactivePrompt": "Type 'yes' to accept the NemoClaw license and and third-party software notice and continue [no]: " +} diff --git a/bin/lib/version.js b/bin/lib/version.js new file mode 100644 index 000000000..eec57e81f --- /dev/null +++ b/bin/lib/version.js @@ -0,0 +1,7 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Thin re-export shim — the implementation lives in src/lib/version.ts, +// compiled to dist/lib/version.js. + +module.exports = require("../../dist/lib/version"); diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index 737c59c16..8a613c3c1 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -12,7 +12,8 @@ const os = require("os"); // Uses exact NVIDIA green #76B900 on truecolor terminals; 256-color otherwise. // --------------------------------------------------------------------------- const _useColor = !process.env.NO_COLOR && !!process.stdout.isTTY; -const _tc = _useColor && (process.env.COLORTERM === "truecolor" || process.env.COLORTERM === "24bit"); +const _tc = + _useColor && (process.env.COLORTERM === "truecolor" || process.env.COLORTERM === "24bit"); const G = _useColor ? (_tc ? "\x1b[38;2;118;185;0m" : "\x1b[38;5;148m") : ""; const B = _useColor ? "\x1b[1m" : ""; const D = _useColor ? "\x1b[2m" : ""; @@ -20,7 +21,17 @@ const R = _useColor ? "\x1b[0m" : ""; const _RD = _useColor ? "\x1b[1;31m" : ""; const YW = _useColor ? "\x1b[1;33m" : ""; -const { ROOT, SCRIPTS, run, runCapture: _runCapture, runInteractive, shellQuote, validateName } = require("./lib/runner"); +const { + ROOT, + SCRIPTS, + run, + runCapture: _runCapture, + runInteractive, + shellQuote, + validateName, +} = require("./lib/runner"); +const { resolveOpenshell } = require("./lib/resolve-openshell"); +const { startGatewayForRecovery } = require("./lib/onboard"); const { ensureApiKey, ensureGithubToken, @@ -30,22 +41,561 @@ const { const registry = require("./lib/registry"); const nim = require("./lib/nim"); const policies = require("./lib/policies"); +const { parseGatewayInference } = require("./lib/inference-config"); +const { getVersion } = require("./lib/version"); +const onboardSession = require("./lib/onboard-session"); +const { parseLiveSandboxNames } = require("./lib/runtime-recovery"); +const { NOTICE_ACCEPT_ENV, NOTICE_ACCEPT_FLAG } = require("./lib/usage-notice"); // ── Global commands ────────────────────────────────────────────── const GLOBAL_COMMANDS = new Set([ - "onboard", "list", "deploy", "setup", "setup-spark", - "start", "stop", "status", "debug", "uninstall", - "help", "--help", "-h", "--version", "-v", + "onboard", + "list", + "deploy", + "setup", + "setup-spark", + "start", + "stop", + "status", + "debug", + "uninstall", + "help", + "--help", + "-h", + "--version", + "-v", ]); -const REMOTE_UNINSTALL_URL = "https://raw.githubusercontent.com/NVIDIA/NemoClaw/refs/heads/main/uninstall.sh"; +const REMOTE_UNINSTALL_URL = + "https://raw.githubusercontent.com/NVIDIA/NemoClaw/refs/heads/main/uninstall.sh"; +let OPENSHELL_BIN = null; +const MIN_LOGS_OPENSHELL_VERSION = "0.0.7"; +const NEMOCLAW_GATEWAY_NAME = "nemoclaw"; +const DASHBOARD_FORWARD_PORT = "18789"; + +function getOpenshellBinary() { + if (!OPENSHELL_BIN) { + OPENSHELL_BIN = resolveOpenshell(); + } + if (!OPENSHELL_BIN) { + console.error("openshell CLI not found. Install OpenShell before using sandbox commands."); + process.exit(1); + } + return OPENSHELL_BIN; +} + +function runOpenshell(args, opts = {}) { + const result = spawnSync(getOpenshellBinary(), args, { + cwd: ROOT, + env: { ...process.env, ...opts.env }, + encoding: "utf-8", + stdio: opts.stdio ?? "inherit", + }); + if (result.status !== 0 && !opts.ignoreError) { + console.error(` Command failed (exit ${result.status}): openshell ${args.join(" ")}`); + process.exit(result.status || 1); + } + return result; +} + +function captureOpenshell(args, opts = {}) { + const result = spawnSync(getOpenshellBinary(), args, { + cwd: ROOT, + env: { ...process.env, ...opts.env }, + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + }); + return { + status: result.status ?? 1, + output: `${result.stdout || ""}${opts.ignoreError ? "" : result.stderr || ""}`.trim(), + }; +} + +function cleanupGatewayAfterLastSandbox() { + runOpenshell(["forward", "stop", DASHBOARD_FORWARD_PORT], { ignoreError: true }); + runOpenshell(["gateway", "destroy", "-g", NEMOCLAW_GATEWAY_NAME], { ignoreError: true }); + run( + `docker volume ls -q --filter "name=openshell-cluster-${NEMOCLAW_GATEWAY_NAME}" | grep . && docker volume ls -q --filter "name=openshell-cluster-${NEMOCLAW_GATEWAY_NAME}" | xargs docker volume rm || true`, + { ignoreError: true }, + ); +} + +function hasNoLiveSandboxes() { + const liveList = captureOpenshell(["sandbox", "list"], { ignoreError: true }); + if (liveList.status !== 0) { + return false; + } + return parseLiveSandboxNames(liveList.output).size === 0; +} + +function isMissingSandboxDeleteResult(output = "") { + return /\bNotFound\b|\bNot Found\b|sandbox not found|sandbox .* not found|sandbox .* not present|sandbox does not exist|no such sandbox/i.test( + stripAnsi(output), + ); +} + +function getSandboxDeleteOutcome(deleteResult) { + const output = `${deleteResult.stdout || ""}${deleteResult.stderr || ""}`.trim(); + return { + output, + alreadyGone: deleteResult.status !== 0 && isMissingSandboxDeleteResult(output), + }; +} + +function parseVersionFromText(value = "") { + const match = String(value || "").match(/([0-9]+\.[0-9]+\.[0-9]+)/); + return match ? match[1] : null; +} + +function versionGte(left = "0.0.0", right = "0.0.0") { + const lhs = String(left) + .split(".") + .map((part) => Number.parseInt(part, 10) || 0); + const rhs = String(right) + .split(".") + .map((part) => Number.parseInt(part, 10) || 0); + const length = Math.max(lhs.length, rhs.length); + for (let index = 0; index < length; index += 1) { + const a = lhs[index] || 0; + const b = rhs[index] || 0; + if (a > b) return true; + if (a < b) return false; + } + return true; +} + +function getInstalledOpenshellVersion() { + const versionResult = captureOpenshell(["--version"], { ignoreError: true }); + return parseVersionFromText(versionResult.output); +} + +function stripAnsi(value = "") { + // eslint-disable-next-line no-control-regex + return String(value).replace(/\x1b\[[0-9;]*m/g, ""); +} + +function buildRecoveredSandboxEntry(name, metadata = {}) { + return { + name, + model: metadata.model || null, + provider: metadata.provider || null, + gpuEnabled: metadata.gpuEnabled === true, + policies: Array.isArray(metadata.policies) + ? metadata.policies + : Array.isArray(metadata.policyPresets) + ? metadata.policyPresets + : [], + nimContainer: metadata.nimContainer || null, + }; +} + +function upsertRecoveredSandbox(name, metadata = {}) { + let validName; + try { + validName = validateName(name, "sandbox name"); + } catch { + return false; + } + + const entry = buildRecoveredSandboxEntry(validName, metadata); + if (registry.getSandbox(validName)) { + registry.updateSandbox(validName, entry); + return false; + } + registry.registerSandbox(entry); + return true; +} + +function shouldRecoverRegistryEntries(current, session, requestedSandboxName) { + const hasSessionSandbox = Boolean(session?.sandboxName); + const missingSessionSandbox = + hasSessionSandbox && !current.sandboxes.some((sandbox) => sandbox.name === session.sandboxName); + const missingRequestedSandbox = + Boolean(requestedSandboxName) && + !current.sandboxes.some((sandbox) => sandbox.name === requestedSandboxName); + const hasRecoverySeed = + current.sandboxes.length > 0 || hasSessionSandbox || Boolean(requestedSandboxName); + return { + missingRequestedSandbox, + shouldRecover: + hasRecoverySeed && + (current.sandboxes.length === 0 || missingRequestedSandbox || missingSessionSandbox), + }; +} + +function seedRecoveryMetadata(current, session, requestedSandboxName) { + const metadataByName = new Map(current.sandboxes.map((sandbox) => [sandbox.name, sandbox])); + let recoveredFromSession = false; + + if (!session?.sandboxName) { + return { metadataByName, recoveredFromSession }; + } + + metadataByName.set( + session.sandboxName, + buildRecoveredSandboxEntry(session.sandboxName, { + model: session.model || null, + provider: session.provider || null, + nimContainer: session.nimContainer || null, + policyPresets: session.policyPresets || null, + }), + ); + const sessionSandboxMissing = !current.sandboxes.some( + (sandbox) => sandbox.name === session.sandboxName, + ); + const shouldRecoverSessionSandbox = + current.sandboxes.length === 0 || + sessionSandboxMissing || + requestedSandboxName === session.sandboxName; + if (shouldRecoverSessionSandbox) { + recoveredFromSession = upsertRecoveredSandbox( + session.sandboxName, + metadataByName.get(session.sandboxName), + ); + } + return { metadataByName, recoveredFromSession }; +} + +async function recoverRegistryFromLiveGateway(metadataByName) { + if (!resolveOpenshell()) { + return 0; + } + const recovery = await recoverNamedGatewayRuntime(); + const canInspectLiveGateway = + recovery.recovered || + recovery.before?.state === "healthy_named" || + recovery.after?.state === "healthy_named"; + if (!canInspectLiveGateway) { + return 0; + } + + let recoveredFromGateway = 0; + const liveList = captureOpenshell(["sandbox", "list"], { ignoreError: true }); + const liveNames = Array.from(parseLiveSandboxNames(liveList.output)); + for (const name of liveNames) { + const metadata = metadataByName.get(name) || {}; + if (upsertRecoveredSandbox(name, metadata)) { + recoveredFromGateway += 1; + } + } + return recoveredFromGateway; +} + +function applyRecoveredDefault(currentDefaultSandbox, requestedSandboxName, session) { + const recovered = registry.listSandboxes(); + const preferredDefault = + requestedSandboxName || (!currentDefaultSandbox ? session?.sandboxName || null : null); + if ( + preferredDefault && + recovered.sandboxes.some((sandbox) => sandbox.name === preferredDefault) + ) { + registry.setDefault(preferredDefault); + } + return registry.listSandboxes(); +} + +async function recoverRegistryEntries({ requestedSandboxName = null } = {}) { + const current = registry.listSandboxes(); + const session = onboardSession.loadSession(); + const recoveryCheck = shouldRecoverRegistryEntries(current, session, requestedSandboxName); + if (!recoveryCheck.shouldRecover) { + return { ...current, recoveredFromSession: false, recoveredFromGateway: 0 }; + } + + const seeded = seedRecoveryMetadata(current, session, requestedSandboxName); + const shouldProbeLiveGateway = current.sandboxes.length > 0 || Boolean(session?.sandboxName); + const recoveredFromGateway = shouldProbeLiveGateway + ? await recoverRegistryFromLiveGateway(seeded.metadataByName) + : 0; + const recovered = applyRecoveredDefault(current.defaultSandbox, requestedSandboxName, session); + return { + ...recovered, + recoveredFromSession: seeded.recoveredFromSession, + recoveredFromGateway, + }; +} + +function hasNamedGateway(output = "") { + return stripAnsi(output).includes("Gateway: nemoclaw"); +} + +function getActiveGatewayName(output = "") { + const match = stripAnsi(output).match(/^\s*Gateway:\s+(.+?)\s*$/m); + return match ? match[1].trim() : ""; +} + +function getNamedGatewayLifecycleState() { + const status = captureOpenshell(["status"]); + const gatewayInfo = captureOpenshell(["gateway", "info", "-g", "nemoclaw"]); + const cleanStatus = stripAnsi(status.output); + const activeGateway = getActiveGatewayName(status.output); + const connected = /^\s*Status:\s*Connected\b/im.test(cleanStatus); + const named = hasNamedGateway(gatewayInfo.output); + const refusing = /Connection refused|client error \(Connect\)|tcp connect error/i.test( + cleanStatus, + ); + if (connected && activeGateway === "nemoclaw" && named) { + return { state: "healthy_named", status: status.output, gatewayInfo: gatewayInfo.output }; + } + if (activeGateway === "nemoclaw" && named && refusing) { + return { state: "named_unreachable", status: status.output, gatewayInfo: gatewayInfo.output }; + } + if (activeGateway === "nemoclaw" && named) { + return { state: "named_unhealthy", status: status.output, gatewayInfo: gatewayInfo.output }; + } + if (connected) { + return { state: "connected_other", status: status.output, gatewayInfo: gatewayInfo.output }; + } + return { state: "missing_named", status: status.output, gatewayInfo: gatewayInfo.output }; +} + +async function recoverNamedGatewayRuntime() { + const before = getNamedGatewayLifecycleState(); + if (before.state === "healthy_named") { + return { recovered: true, before, after: before, attempted: false }; + } + + runOpenshell(["gateway", "select", "nemoclaw"], { ignoreError: true }); + let after = getNamedGatewayLifecycleState(); + if (after.state === "healthy_named") { + process.env.OPENSHELL_GATEWAY = "nemoclaw"; + return { recovered: true, before, after, attempted: true, via: "select" }; + } + + const shouldStartGateway = [before.state, after.state].some((state) => + ["missing_named", "named_unhealthy", "named_unreachable", "connected_other"].includes(state), + ); + + if (shouldStartGateway) { + try { + await startGatewayForRecovery(); + } catch { + // Fall through to the lifecycle re-check below so we preserve the + // existing recovery result shape and emit the correct classification. + } + runOpenshell(["gateway", "select", "nemoclaw"], { ignoreError: true }); + after = getNamedGatewayLifecycleState(); + if (after.state === "healthy_named") { + process.env.OPENSHELL_GATEWAY = "nemoclaw"; + return { recovered: true, before, after, attempted: true, via: "start" }; + } + } + + return { recovered: false, before, after, attempted: true }; +} + +function getSandboxGatewayState(sandboxName) { + const result = captureOpenshell(["sandbox", "get", sandboxName]); + const output = result.output; + if (result.status === 0) { + return { state: "present", output }; + } + if (/\bNotFound\b|\bNot Found\b|sandbox not found/i.test(output)) { + return { state: "missing", output }; + } + if ( + /transport error|Connection refused|handshake verification failed|Missing gateway auth token|device identity required/i.test( + output, + ) + ) { + return { state: "gateway_error", output }; + } + return { state: "unknown_error", output }; +} + +function printGatewayLifecycleHint(output = "", sandboxName = "", writer = console.error) { + const cleanOutput = stripAnsi(output); + if (/No gateway configured/i.test(cleanOutput)) { + writer( + " The selected NemoClaw gateway is no longer configured or its metadata/runtime has been lost.", + ); + writer( + " Start the gateway again with `openshell gateway start --name nemoclaw` before expecting existing sandboxes to reconnect.", + ); + writer( + " If the gateway has to be rebuilt from scratch, recreate the affected sandbox afterward.", + ); + return; + } + if ( + /Connection refused|client error \(Connect\)|tcp connect error/i.test(cleanOutput) && + /Gateway:\s+nemoclaw/i.test(cleanOutput) + ) { + writer( + " The selected NemoClaw gateway exists in metadata, but its API is refusing connections after restart.", + ); + writer(" This usually means the gateway runtime did not come back cleanly after the restart."); + writer( + " Retry `openshell gateway start --name nemoclaw`; if it stays in this state, rebuild the gateway before expecting existing sandboxes to reconnect.", + ); + return; + } + if (/handshake verification failed/i.test(cleanOutput)) { + writer(" This looks like gateway identity drift after restart."); + writer( + " Existing sandboxes may still be recorded locally, but the current gateway no longer trusts their prior connection state.", + ); + writer( + " Try re-establishing the NemoClaw gateway/runtime first. If the sandbox is still unreachable, recreate just that sandbox with `nemoclaw onboard`.", + ); + return; + } + if (/Connection refused|transport error/i.test(cleanOutput)) { + writer( + ` The sandbox '${sandboxName}' may still exist, but the current gateway/runtime is not reachable.`, + ); + writer(" Check `openshell status`, verify the active gateway, and retry."); + return; + } + if (/Missing gateway auth token|device identity required/i.test(cleanOutput)) { + writer( + " The gateway is reachable, but the current auth or device identity state is not usable.", + ); + writer(" Verify the active gateway and retry after re-establishing the runtime."); + } +} + +// eslint-disable-next-line complexity +async function getReconciledSandboxGatewayState(sandboxName) { + let lookup = getSandboxGatewayState(sandboxName); + if (lookup.state === "present") { + return lookup; + } + if (lookup.state === "missing") { + return lookup; + } + + if (lookup.state === "gateway_error") { + const recovery = await recoverNamedGatewayRuntime(); + if (recovery.recovered) { + const retried = getSandboxGatewayState(sandboxName); + if (retried.state === "present" || retried.state === "missing") { + return { ...retried, recoveredGateway: true, recoveryVia: recovery.via || null }; + } + if (/handshake verification failed/i.test(retried.output)) { + return { + state: "identity_drift", + output: retried.output, + recoveredGateway: true, + recoveryVia: recovery.via || null, + }; + } + return { ...retried, recoveredGateway: true, recoveryVia: recovery.via || null }; + } + const latestLifecycle = getNamedGatewayLifecycleState(); + const latestStatus = stripAnsi(latestLifecycle.status || ""); + if (/No gateway configured/i.test(latestStatus)) { + return { + state: "gateway_missing_after_restart", + output: latestLifecycle.status || lookup.output, + }; + } + if ( + /Connection refused|client error \(Connect\)|tcp connect error/i.test(latestStatus) && + /Gateway:\s+nemoclaw/i.test(latestStatus) + ) { + return { + state: "gateway_unreachable_after_restart", + output: latestLifecycle.status || lookup.output, + }; + } + if ( + recovery.after?.state === "named_unreachable" || + recovery.before?.state === "named_unreachable" + ) { + return { + state: "gateway_unreachable_after_restart", + output: recovery.after?.status || recovery.before?.status || lookup.output, + }; + } + return { ...lookup, gatewayRecoveryFailed: true }; + } + + return lookup; +} + +async function ensureLiveSandboxOrExit(sandboxName) { + const lookup = await getReconciledSandboxGatewayState(sandboxName); + if (lookup.state === "present") { + return lookup; + } + if (lookup.state === "missing") { + registry.removeSandbox(sandboxName); + console.error(` Sandbox '${sandboxName}' is not present in the live OpenShell gateway.`); + console.error(" Removed stale local registry entry."); + console.error( + " Run `nemoclaw list` to confirm the remaining sandboxes, or `nemoclaw onboard` to create a new one.", + ); + process.exit(1); + } + if (lookup.state === "identity_drift") { + console.error( + ` Sandbox '${sandboxName}' is recorded locally, but the gateway trust material rotated after restart.`, + ); + if (lookup.output) { + console.error(lookup.output); + } + console.error( + " Existing sandbox connections cannot be reattached safely after this gateway identity change.", + ); + console.error( + " Recreate this sandbox with `nemoclaw onboard` once the gateway runtime is stable.", + ); + process.exit(1); + } + if (lookup.state === "gateway_unreachable_after_restart") { + console.error( + ` Sandbox '${sandboxName}' may still exist, but the selected NemoClaw gateway is still refusing connections after restart.`, + ); + if (lookup.output) { + console.error(lookup.output); + } + console.error( + " Retry `openshell gateway start --name nemoclaw` and verify `openshell status` is healthy before reconnecting.", + ); + console.error( + " If the gateway never becomes healthy, rebuild the gateway and then recreate the affected sandbox.", + ); + process.exit(1); + } + if (lookup.state === "gateway_missing_after_restart") { + console.error( + ` Sandbox '${sandboxName}' may still exist locally, but the NemoClaw gateway is no longer configured after restart/rebuild.`, + ); + if (lookup.output) { + console.error(lookup.output); + } + console.error( + " Start the gateway again with `openshell gateway start --name nemoclaw` before retrying.", + ); + console.error( + " If the gateway had to be rebuilt from scratch, recreate the affected sandbox afterward.", + ); + process.exit(1); + } + console.error(` Unable to verify sandbox '${sandboxName}' against the live OpenShell gateway.`); + if (lookup.output) { + console.error(lookup.output); + } + printGatewayLifecycleHint(lookup.output, sandboxName); + console.error(" Check `openshell status` and the active gateway, then retry."); + process.exit(1); +} + +function printOldLogsCompatibilityGuidance(installedVersion = null) { + const versionText = installedVersion ? ` (${installedVersion})` : ""; + console.error( + ` Installed OpenShell${versionText} is too old or incompatible with \`nemoclaw logs\`.`, + ); + console.error(` NemoClaw expects \`openshell logs \` and live streaming via \`--tail\`.`); + console.error( + " Upgrade OpenShell by rerunning `nemoclaw onboard`, or reinstall the OpenShell CLI and try again.", + ); +} function resolveUninstallScript() { - const candidates = [ - path.join(ROOT, "uninstall.sh"), - path.join(__dirname, "..", "uninstall.sh"), - ]; + const candidates = [path.join(ROOT, "uninstall.sh"), path.join(__dirname, "..", "uninstall.sh")]; for (const candidate of candidates) { if (fs.existsSync(candidate)) { @@ -73,26 +623,27 @@ function exitWithSpawnResult(result) { async function onboard(args) { const { onboard: runOnboard } = require("./lib/onboard"); - const allowedArgs = new Set(["--non-interactive"]); + const allowedArgs = new Set(["--non-interactive", "--resume", NOTICE_ACCEPT_FLAG]); const unknownArgs = args.filter((arg) => !allowedArgs.has(arg)); if (unknownArgs.length > 0) { console.error(` Unknown onboard option(s): ${unknownArgs.join(", ")}`); - console.error(" Usage: nemoclaw onboard [--non-interactive]"); + console.error( + ` Usage: nemoclaw onboard [--non-interactive] [--resume] [${NOTICE_ACCEPT_FLAG}]`, + ); process.exit(1); } const nonInteractive = args.includes("--non-interactive"); - await runOnboard({ nonInteractive }); + const resume = args.includes("--resume"); + const acceptThirdPartySoftware = + args.includes(NOTICE_ACCEPT_FLAG) || String(process.env[NOTICE_ACCEPT_ENV] || "") === "1"; + await runOnboard({ nonInteractive, resume, acceptThirdPartySoftware }); } -async function setup() { +async function setup(args = []) { console.log(""); console.log(" ⚠ `nemoclaw setup` is deprecated. Use `nemoclaw onboard` instead."); - console.log(" Running legacy setup.sh for backwards compatibility..."); console.log(""); - await ensureApiKey(); - const { defaultSandbox } = registry.listSandboxes(); - const safeName = defaultSandbox && /^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(defaultSandbox) ? defaultSandbox : ""; - run(`bash "${SCRIPTS}/setup.sh" ${shellQuote(safeName)}`); + await onboard(args); } async function setupSpark() { @@ -100,6 +651,7 @@ async function setupSpark() { run(`sudo bash "${SCRIPTS}/setup-spark.sh"`); } +// eslint-disable-next-line complexity async function deploy(instanceName) { if (!instanceName) { console.error(" Usage: nemoclaw deploy "); @@ -151,7 +703,11 @@ async function deploy(instanceName) { process.stdout.write(` Waiting for SSH `); for (let i = 0; i < 60; i++) { try { - execFileSync("ssh", ["-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=no", name, "echo", "ok"], { encoding: "utf-8", stdio: "ignore" }); + execFileSync( + "ssh", + ["-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=no", name, "echo", "ok"], + { encoding: "utf-8", stdio: "ignore" }, + ); process.stdout.write(` ${G}✓${R}\n`); break; } catch { @@ -166,8 +722,12 @@ async function deploy(instanceName) { } console.log(" Syncing NemoClaw to VM..."); - run(`ssh -o StrictHostKeyChecking=no -o LogLevel=ERROR ${qname} 'mkdir -p /home/ubuntu/nemoclaw'`); - run(`rsync -az --delete --exclude node_modules --exclude .git --exclude src -e "ssh -o StrictHostKeyChecking=no -o LogLevel=ERROR" "${ROOT}/scripts" "${ROOT}/Dockerfile" "${ROOT}/nemoclaw" "${ROOT}/nemoclaw-blueprint" "${ROOT}/bin" "${ROOT}/package.json" ${qname}:/home/ubuntu/nemoclaw/`); + run( + `ssh -o StrictHostKeyChecking=no -o LogLevel=ERROR ${qname} 'mkdir -p /home/ubuntu/nemoclaw'`, + ); + run( + `rsync -az --delete --exclude node_modules --exclude .git --exclude src -e "ssh -o StrictHostKeyChecking=no -o LogLevel=ERROR" "${ROOT}/scripts" "${ROOT}/Dockerfile" "${ROOT}/nemoclaw" "${ROOT}/nemoclaw-blueprint" "${ROOT}/bin" "${ROOT}/package.json" ${qname}:/home/ubuntu/nemoclaw/`, + ); const envLines = [`NVIDIA_API_KEY=${shellQuote(process.env.NVIDIA_API_KEY || "")}`]; const ghToken = process.env.GITHUB_TOKEN; @@ -182,49 +742,104 @@ async function deploy(instanceName) { const envTmp = path.join(envDir, "env"); fs.writeFileSync(envTmp, envLines.join("\n") + "\n", { mode: 0o600 }); try { - run(`scp -q -o StrictHostKeyChecking=no -o LogLevel=ERROR ${shellQuote(envTmp)} ${qname}:/home/ubuntu/nemoclaw/.env`); - run(`ssh -q -o StrictHostKeyChecking=no -o LogLevel=ERROR ${qname} 'chmod 600 /home/ubuntu/nemoclaw/.env'`); + run( + `scp -q -o StrictHostKeyChecking=no -o LogLevel=ERROR ${shellQuote(envTmp)} ${qname}:/home/ubuntu/nemoclaw/.env`, + ); + run( + `ssh -q -o StrictHostKeyChecking=no -o LogLevel=ERROR ${qname} 'chmod 600 /home/ubuntu/nemoclaw/.env'`, + ); } finally { - try { fs.unlinkSync(envTmp); } catch { /* ignored */ } - try { fs.rmdirSync(envDir); } catch { /* ignored */ } + try { + fs.unlinkSync(envTmp); + } catch { + /* ignored */ + } + try { + fs.rmdirSync(envDir); + } catch { + /* ignored */ + } } console.log(" Running setup..."); - runInteractive(`ssh -t -o StrictHostKeyChecking=no -o LogLevel=ERROR ${qname} 'cd /home/ubuntu/nemoclaw && set -a && . .env && set +a && bash scripts/brev-setup.sh'`); + runInteractive( + `ssh -t -o StrictHostKeyChecking=no -o LogLevel=ERROR ${qname} 'cd /home/ubuntu/nemoclaw && set -a && . .env && set +a && bash scripts/brev-setup.sh'`, + ); if (tgToken) { console.log(" Starting services..."); - run(`ssh -o StrictHostKeyChecking=no -o LogLevel=ERROR ${qname} 'cd /home/ubuntu/nemoclaw && set -a && . .env && set +a && bash scripts/start-services.sh'`); + run( + `ssh -o StrictHostKeyChecking=no -o LogLevel=ERROR ${qname} 'cd /home/ubuntu/nemoclaw && set -a && . .env && set +a && bash scripts/start-services.sh'`, + ); } console.log(""); console.log(" Connecting to sandbox..."); console.log(""); - runInteractive(`ssh -t -o StrictHostKeyChecking=no -o LogLevel=ERROR ${qname} 'cd /home/ubuntu/nemoclaw && set -a && . .env && set +a && openshell sandbox connect nemoclaw'`); + runInteractive( + `ssh -t -o StrictHostKeyChecking=no -o LogLevel=ERROR ${qname} 'cd /home/ubuntu/nemoclaw && set -a && . .env && set +a && openshell sandbox connect nemoclaw'`, + ); } async function start() { - await ensureApiKey(); + const { startAll } = require("./lib/services"); const { defaultSandbox } = registry.listSandboxes(); - const safeName = defaultSandbox && /^[a-zA-Z0-9._-]+$/.test(defaultSandbox) ? defaultSandbox : null; - const sandboxEnv = safeName ? `SANDBOX_NAME=${shellQuote(safeName)}` : ""; - run(`${sandboxEnv} bash "${SCRIPTS}/start-services.sh"`); + const safeName = + defaultSandbox && /^[a-zA-Z0-9._-]+$/.test(defaultSandbox) ? defaultSandbox : null; + await startAll({ sandboxName: safeName || undefined }); } function stop() { - run(`bash "${SCRIPTS}/start-services.sh" --stop`); + const { stopAll } = require("./lib/services"); + const { defaultSandbox } = registry.listSandboxes(); + const safeName = + defaultSandbox && /^[a-zA-Z0-9._-]+$/.test(defaultSandbox) ? defaultSandbox : null; + stopAll({ sandboxName: safeName || undefined }); } function debug(args) { - const result = spawnSync("bash", [path.join(SCRIPTS, "debug.sh"), ...args], { - stdio: "inherit", - cwd: ROOT, - env: { - ...process.env, - SANDBOX_NAME: registry.listSandboxes().defaultSandbox || "", - }, - }); - exitWithSpawnResult(result); + const { runDebug } = require("./lib/debug"); + const opts = {}; + for (let i = 0; i < args.length; i++) { + switch (args[i]) { + case "--help": + case "-h": + console.log("Collect NemoClaw diagnostic information\n"); + console.log("Usage: nemoclaw debug [--quick] [--output FILE] [--sandbox NAME]\n"); + console.log("Options:"); + console.log(" --quick, -q Only collect minimal diagnostics"); + console.log(" --output, -o FILE Write a tarball to FILE"); + console.log(" --sandbox NAME Target sandbox name"); + process.exit(0); + break; + case "--quick": + case "-q": + opts.quick = true; + break; + case "--output": + case "-o": + if (!args[i + 1] || args[i + 1].startsWith("-")) { + console.error("Error: --output requires a file path argument"); + process.exit(1); + } + opts.output = args[++i]; + break; + case "--sandbox": + if (!args[i + 1] || args[i + 1].startsWith("-")) { + console.error("Error: --sandbox requires a name argument"); + process.exit(1); + } + opts.sandboxName = args[++i]; + break; + default: + console.error(`Unknown option: ${args[i]}`); + process.exit(1); + } + } + if (!opts.sandboxName) { + opts.sandboxName = registry.listSandboxes().defaultSandbox || undefined; + } + runDebug(opts); } function uninstall(args) { @@ -239,16 +854,33 @@ function uninstall(args) { exitWithSpawnResult(result); } + // Download to file before execution — prevents partial-download execution. + // Upstream URL is a rolling release so SHA-256 pinning isn't practical. console.log(` Local uninstall script not found; falling back to ${REMOTE_UNINSTALL_URL}`); - const forwardedArgs = args.map(shellQuote).join(" "); - const command = forwardedArgs.length > 0 - ? `curl -fsSL ${shellQuote(REMOTE_UNINSTALL_URL)} | bash -s -- ${forwardedArgs}` - : `curl -fsSL ${shellQuote(REMOTE_UNINSTALL_URL)} | bash`; - const result = spawnSync("bash", ["-c", command], { - stdio: "inherit", - cwd: ROOT, - env: process.env, - }); + const uninstallDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-")); + const uninstallScript = path.join(uninstallDir, "uninstall.sh"); + let result; + let downloadFailed = false; + try { + try { + execFileSync("curl", ["-fsSL", REMOTE_UNINSTALL_URL, "-o", uninstallScript], { + stdio: "inherit", + }); + } catch { + console.error(` Failed to download uninstall script from ${REMOTE_UNINSTALL_URL}`); + downloadFailed = true; + } + if (!downloadFailed) { + result = spawnSync("bash", [uninstallScript, ...args], { + stdio: "inherit", + cwd: ROOT, + env: process.env, + }); + } + } finally { + fs.rmSync(uninstallDir, { recursive: true, force: true }); + } + if (downloadFailed) process.exit(1); exitWithSpawnResult(result); } @@ -256,35 +888,65 @@ function showStatus() { // Show sandbox registry const { sandboxes, defaultSandbox } = registry.listSandboxes(); if (sandboxes.length > 0) { + const live = parseGatewayInference( + captureOpenshell(["inference", "get"], { ignoreError: true }).output, + ); console.log(""); console.log(" Sandboxes:"); for (const sb of sandboxes) { const def = sb.name === defaultSandbox ? " *" : ""; - const model = sb.model ? ` (${sb.model})` : ""; - console.log(` ${sb.name}${def}${model}`); + const model = (live && live.model) || sb.model; + console.log(` ${sb.name}${def}${model ? ` (${model})` : ""}`); } console.log(""); } // Show service status - run(`bash "${SCRIPTS}/start-services.sh" --status`); + const { showStatus: showServiceStatus } = require("./lib/services"); + showServiceStatus({ sandboxName: defaultSandbox || undefined }); } -function listSandboxes() { - const { sandboxes, defaultSandbox } = registry.listSandboxes(); +async function listSandboxes() { + const recovery = await recoverRegistryEntries(); + const { sandboxes, defaultSandbox } = recovery; if (sandboxes.length === 0) { console.log(""); - console.log(" No sandboxes registered. Run `nemoclaw onboard` to get started."); + const session = onboardSession.loadSession(); + if (session?.sandboxName) { + console.log( + ` No sandboxes registered locally, but the last onboarded sandbox was '${session.sandboxName}'.`, + ); + console.log( + " Retry `nemoclaw connect` or `nemoclaw status` once the gateway/runtime is healthy.", + ); + } else { + console.log(" No sandboxes registered. Run `nemoclaw onboard` to get started."); + } console.log(""); return; } + // Query live gateway inference once; prefer it over stale registry values. + const live = parseGatewayInference( + captureOpenshell(["inference", "get"], { ignoreError: true }).output, + ); + console.log(""); + if (recovery.recoveredFromSession) { + console.log(" Recovered sandbox inventory from the last onboard session."); + console.log(""); + } + if (recovery.recoveredFromGateway > 0) { + console.log( + ` Recovered ${recovery.recoveredFromGateway} sandbox entr${recovery.recoveredFromGateway === 1 ? "y" : "ies"} from the live OpenShell gateway.`, + ); + console.log(""); + } console.log(" Sandboxes:"); for (const sb of sandboxes) { const def = sb.name === defaultSandbox ? " *" : ""; - const model = sb.model || "unknown"; - const provider = sb.provider || "unknown"; + const model = (live && live.model) || sb.model || "unknown"; + const provider = (live && live.provider) || sb.provider || "unknown"; const gpu = sb.gpuEnabled ? "GPU" : "CPU"; const presets = sb.policies && sb.policies.length > 0 ? sb.policies.join(", ") : "none"; console.log(` ${sb.name}${def}`); @@ -297,30 +959,103 @@ function listSandboxes() { // ── Sandbox-scoped actions ─────────────────────────────────────── -function sandboxConnect(sandboxName) { - const qn = shellQuote(sandboxName); - // Ensure port forward is alive before connecting - run(`openshell forward start --background 18789 ${qn} 2>/dev/null || true`, { ignoreError: true }); - runInteractive(`openshell sandbox connect ${qn}`); +async function sandboxConnect(sandboxName) { + await ensureLiveSandboxOrExit(sandboxName); + const result = spawnSync(getOpenshellBinary(), ["sandbox", "connect", sandboxName], { + stdio: "inherit", + cwd: ROOT, + env: process.env, + }); + exitWithSpawnResult(result); } -function sandboxStatus(sandboxName) { +// eslint-disable-next-line complexity +async function sandboxStatus(sandboxName) { const sb = registry.getSandbox(sandboxName); + const live = parseGatewayInference( + captureOpenshell(["inference", "get"], { ignoreError: true }).output, + ); if (sb) { console.log(""); console.log(` Sandbox: ${sb.name}`); - console.log(` Model: ${sb.model || "unknown"}`); - console.log(` Provider: ${sb.provider || "unknown"}`); + console.log(` Model: ${(live && live.model) || sb.model || "unknown"}`); + console.log(` Provider: ${(live && live.provider) || sb.provider || "unknown"}`); console.log(` GPU: ${sb.gpuEnabled ? "yes" : "no"}`); console.log(` Policies: ${(sb.policies || []).join(", ") || "none"}`); } - // openshell info - run(`openshell sandbox get ${shellQuote(sandboxName)} 2>/dev/null || true`, { ignoreError: true }); + const lookup = await getReconciledSandboxGatewayState(sandboxName); + if (lookup.state === "present") { + console.log(""); + if (lookup.recoveredGateway) { + console.log( + ` Recovered NemoClaw gateway runtime via ${lookup.recoveryVia || "gateway reattach"}.`, + ); + console.log(""); + } + console.log(lookup.output); + } else if (lookup.state === "missing") { + registry.removeSandbox(sandboxName); + console.log(""); + console.log(` Sandbox '${sandboxName}' is not present in the live OpenShell gateway.`); + console.log(" Removed stale local registry entry."); + } else if (lookup.state === "identity_drift") { + console.log(""); + console.log( + ` Sandbox '${sandboxName}' is recorded locally, but the gateway trust material rotated after restart.`, + ); + if (lookup.output) { + console.log(lookup.output); + } + console.log( + " Existing sandbox connections cannot be reattached safely after this gateway identity change.", + ); + console.log( + " Recreate this sandbox with `nemoclaw onboard` once the gateway runtime is stable.", + ); + } else if (lookup.state === "gateway_unreachable_after_restart") { + console.log(""); + console.log( + ` Sandbox '${sandboxName}' may still exist, but the selected NemoClaw gateway is still refusing connections after restart.`, + ); + if (lookup.output) { + console.log(lookup.output); + } + console.log( + " Retry `openshell gateway start --name nemoclaw` and verify `openshell status` is healthy before reconnecting.", + ); + console.log( + " If the gateway never becomes healthy, rebuild the gateway and then recreate the affected sandbox.", + ); + } else if (lookup.state === "gateway_missing_after_restart") { + console.log(""); + console.log( + ` Sandbox '${sandboxName}' may still exist locally, but the NemoClaw gateway is no longer configured after restart/rebuild.`, + ); + if (lookup.output) { + console.log(lookup.output); + } + console.log( + " Start the gateway again with `openshell gateway start --name nemoclaw` before retrying.", + ); + console.log( + " If the gateway had to be rebuilt from scratch, recreate the affected sandbox afterward.", + ); + } else { + console.log(""); + console.log(` Could not verify sandbox '${sandboxName}' against the live OpenShell gateway.`); + if (lookup.output) { + console.log(lookup.output); + } + printGatewayLifecycleHint(lookup.output, sandboxName, console.log); + } // NIM health - const nimStat = sb && sb.nimContainer ? nim.nimStatusByName(sb.nimContainer) : nim.nimStatus(sandboxName); - console.log(` NIM: ${nimStat.running ? `running (${nimStat.container})` : "not running"}`); + const nimStat = + sb && sb.nimContainer ? nim.nimStatusByName(sb.nimContainer) : nim.nimStatus(sandboxName); + console.log( + ` NIM: ${nimStat.running ? `running (${nimStat.container})` : "not running"}`, + ); if (nimStat.running) { console.log(` Healthy: ${nimStat.healthy ? "yes" : "no"}`); } @@ -328,24 +1063,54 @@ function sandboxStatus(sandboxName) { } function sandboxLogs(sandboxName, follow) { - const followFlag = follow ? " --tail" : ""; - run(`openshell logs ${shellQuote(sandboxName)}${followFlag}`); + const installedVersion = getInstalledOpenshellVersion(); + if (installedVersion && !versionGte(installedVersion, MIN_LOGS_OPENSHELL_VERSION)) { + printOldLogsCompatibilityGuidance(installedVersion); + process.exit(1); + } + + const args = ["logs", sandboxName]; + if (follow) args.push("--tail"); + const result = spawnSync(getOpenshellBinary(), args, { + cwd: ROOT, + env: process.env, + encoding: "utf-8", + stdio: follow ? ["ignore", "inherit", "pipe"] : ["ignore", "pipe", "pipe"], + }); + const stdout = String(result.stdout || ""); + const stderr = String(result.stderr || ""); + const combined = `${stdout}${stderr}`; + if (!follow && stdout) { + process.stdout.write(stdout); + } + if (result.status === 0) { + return; + } + if (stderr) { + process.stderr.write(stderr); + } + if ( + /unrecognized subcommand 'logs'|unexpected argument '--tail'|unexpected argument '--follow'/i.test( + combined, + ) || + (installedVersion && !versionGte(installedVersion, MIN_LOGS_OPENSHELL_VERSION)) + ) { + printOldLogsCompatibilityGuidance(installedVersion); + process.exit(1); + } + if (result.status === null || result.signal) { + exitWithSpawnResult(result); + } + console.error(` Command failed (exit ${result.status}): openshell ${args.join(" ")}`); + exitWithSpawnResult(result); } async function sandboxPolicyAdd(sandboxName) { const allPresets = policies.listPresets(); const applied = policies.getAppliedPresets(sandboxName); - console.log(""); - console.log(" Available presets:"); - allPresets.forEach((p) => { - const marker = applied.includes(p.name) ? "●" : "○"; - console.log(` ${marker} ${p.name} — ${p.description}`); - }); - console.log(""); - const { prompt: askPrompt } = require("./lib/credentials"); - const answer = await askPrompt(" Preset to apply: "); + const answer = await policies.selectFromList(allPresets, { applied }); if (!answer) return; const confirm = await askPrompt(` Apply '${answer}' to sandbox '${sandboxName}'? [Y/n]: `); @@ -386,22 +1151,45 @@ async function sandboxDestroy(sandboxName, args = []) { else nim.stopNimContainer(sandboxName); console.log(` Deleting sandbox '${sandboxName}'...`); - run(`openshell sandbox delete ${shellQuote(sandboxName)} 2>/dev/null || true`, { ignoreError: true }); + const deleteResult = runOpenshell(["sandbox", "delete", sandboxName], { + ignoreError: true, + stdio: ["ignore", "pipe", "pipe"], + }); + const { output: deleteOutput, alreadyGone } = getSandboxDeleteOutcome(deleteResult); + + if (deleteResult.status !== 0 && !alreadyGone) { + if (deleteOutput) { + console.error(` ${deleteOutput}`); + } + console.error(` Failed to destroy sandbox '${sandboxName}'.`); + process.exit(deleteResult.status || 1); + } - registry.removeSandbox(sandboxName); + const removed = registry.removeSandbox(sandboxName); + if ( + (deleteResult.status === 0 || alreadyGone) && + removed && + registry.listSandboxes().sandboxes.length === 0 && + hasNoLiveSandboxes() + ) { + cleanupGatewayAfterLastSandbox(); + } + if (alreadyGone) { + console.log(` Sandbox '${sandboxName}' was already absent from the live gateway.`); + } console.log(` ${G}✓${R} Sandbox '${sandboxName}' destroyed`); } // ── Help ───────────────────────────────────────────────────────── function help() { - const pkg = require(path.join(__dirname, "..", "package.json")); console.log(` - ${B}${G}NemoClaw${R} ${D}v${pkg.version}${R} + ${B}${G}NemoClaw${R} ${D}v${getVersion()}${R} ${D}Deploy more secure, always-on AI assistants with a single command.${R} ${G}Getting Started:${R} ${B}nemoclaw onboard${R} Configure inference endpoint and credentials + ${D}(non-interactive: ${NOTICE_ACCEPT_FLAG} or ${NOTICE_ACCEPT_ENV}=1)${R} nemoclaw setup-spark Set up on DGX Spark ${D}(fixes cgroup v2 + Docker)${R} ${G}Sandbox Management:${R} @@ -445,6 +1233,7 @@ function help() { const [cmd, ...args] = process.argv.slice(2); +// eslint-disable-next-line complexity (async () => { // No command → help if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") { @@ -455,23 +1244,44 @@ const [cmd, ...args] = process.argv.slice(2); // Global commands if (GLOBAL_COMMANDS.has(cmd)) { switch (cmd) { - case "onboard": await onboard(args); break; - case "setup": await setup(); break; - case "setup-spark": await setupSpark(); break; - case "deploy": await deploy(args[0]); break; - case "start": await start(); break; - case "stop": stop(); break; - case "status": showStatus(); break; - case "debug": debug(args); break; - case "uninstall": uninstall(args); break; - case "list": listSandboxes(); break; + case "onboard": + await onboard(args); + break; + case "setup": + await setup(args); + break; + case "setup-spark": + await setupSpark(); + break; + case "deploy": + await deploy(args[0]); + break; + case "start": + await start(); + break; + case "stop": + stop(); + break; + case "status": + showStatus(); + break; + case "debug": + debug(args); + break; + case "uninstall": + uninstall(args); + break; + case "list": + await listSandboxes(); + break; case "--version": case "-v": { - const pkg = require(path.join(__dirname, "..", "package.json")); - console.log(`nemoclaw v${pkg.version}`); + console.log(`nemoclaw v${getVersion()}`); break; } - default: help(); break; + default: + help(); + break; } return; } @@ -484,12 +1294,24 @@ const [cmd, ...args] = process.argv.slice(2); const actionArgs = args.slice(1); switch (action) { - case "connect": sandboxConnect(cmd); break; - case "status": sandboxStatus(cmd); break; - case "logs": sandboxLogs(cmd, actionArgs.includes("--follow")); break; - case "policy-add": await sandboxPolicyAdd(cmd); break; - case "policy-list": sandboxPolicyList(cmd); break; - case "destroy": await sandboxDestroy(cmd, actionArgs); break; + case "connect": + await sandboxConnect(cmd); + break; + case "status": + await sandboxStatus(cmd); + break; + case "logs": + sandboxLogs(cmd, actionArgs.includes("--follow")); + break; + case "policy-add": + await sandboxPolicyAdd(cmd); + break; + case "policy-list": + sandboxPolicyList(cmd); + break; + case "destroy": + await sandboxDestroy(cmd, actionArgs); + break; default: console.error(` Unknown action: ${action}`); console.error(` Valid actions: connect, status, logs, policy-add, policy-list, destroy`); @@ -498,6 +1320,15 @@ const [cmd, ...args] = process.argv.slice(2); return; } + if (args[0] === "connect") { + validateName(cmd, "sandbox name"); + await recoverRegistryEntries({ requestedSandboxName: cmd }); + if (registry.getSandbox(cmd)) { + await sandboxConnect(cmd); + return; + } + } + // Unknown command — suggest console.error(` Unknown command: ${cmd}`); console.error(""); diff --git a/ci/coverage-threshold-cli.json b/ci/coverage-threshold-cli.json new file mode 100644 index 000000000..58b6e5ac5 --- /dev/null +++ b/ci/coverage-threshold-cli.json @@ -0,0 +1,6 @@ +{ + "lines": 22, + "functions": 32, + "branches": 22, + "statements": 22 +} diff --git a/ci/coverage-threshold.json b/ci/coverage-threshold-plugin.json similarity index 100% rename from ci/coverage-threshold.json rename to ci/coverage-threshold-plugin.json diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 5448d4634..236edf168 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -2,71 +2,86 @@ This guide covers how to write, edit, and review documentation for NemoClaw. If you change code that affects user-facing behavior, update the relevant docs in the same PR. -## Use the Agent Skills +## When to Update Docs -If you use an AI coding agent (Cursor, Claude Code, Codex, etc.), the repo includes skills that automate doc work. Use them before writing from scratch. +Update documentation when your change: -| Skill | What it does | When to use | -|---|---|---| -| `update-docs` | Scans recent commits for user-facing changes and drafts doc updates. | After landing features, before a release, or to find doc gaps. | +- Adds, removes, or renames a CLI command or flag. +- Changes default behavior or configuration. +- Adds a new feature that users interact with. +- Fixes a bug that the docs describe incorrectly. +- Changes an API, protocol, or policy schema. + +## Update Docs with Agent Skills + +If you use an AI coding agent (Cursor, Claude Code, Codex, etc.), the repo includes the `update-docs` skill that automates doc work. +Use it before writing from scratch. + +The skill scans recent commits for user-facing changes and drafts doc updates. +Run it after landing features, before a release, or to find doc gaps. +For example, ask your agent to "catch up the docs for everything merged since v0.2.0". + +The skill lives in `.agents/skills/update-docs/` and follows the style guide below automatically. -The skills live in `.agents/skills/` and follow the style guide below automatically. To use one, ask your agent to run it. For example, ask it to "catch up the docs for everything merged since v0.2.0". +## Doc-to-Skills Pipeline -### Doc-derived skills +The `docs/` directory is the source of truth for user-facing documentation. +The script `scripts/docs-to-skills.py` converts doc pages into agent skills under `.agents/skills/`. +These generated skills let AI agents walk users through NemoClaw tasks (installation, inference configuration, policy management, monitoring, and more) without reading raw doc pages. -Always edit pages in `docs/`. Never edit files under `.agents/skills/docs/` — that entire directory is autogenerated by `scripts/docs-to-skills.py` and your changes will be overwritten on the next run. +Always edit pages in `docs/`. +Never edit generated skill files under `.agents/skills/nemoclaw-*/`. Your changes will be overwritten on the next run. -In addition to the `update-docs` skill, the repo ships generated skills in `.agents/skills/docs/` that let agents walk users through NemoClaw tasks (installation, inference configuration, policy management, monitoring, and more). These are derived from the `docs/` pages using the script above. +### Generated skills -The current generated skills are: +The current generated skills and their source pages are: | Skill | Source docs | |---|---| | `nemoclaw-overview` | `docs/about/overview.md`, `docs/about/how-it-works.md`, `docs/about/release-notes.md` | | `nemoclaw-get-started` | `docs/get-started/quickstart.md` | -| `nemoclaw-configure-inference` | `docs/inference/switch-inference-providers.md` | +| `nemoclaw-configure-inference` | `docs/inference/inference-options.md`, `docs/inference/use-local-inference.md`, `docs/inference/switch-inference-providers.md` | | `nemoclaw-manage-policy` | `docs/network-policy/customize-network-policy.md`, `docs/network-policy/approve-network-requests.md` | | `nemoclaw-monitor-sandbox` | `docs/monitoring/monitor-sandbox-activity.md` | | `nemoclaw-deploy-remote` | `docs/deployment/deploy-to-remote-gpu.md`, `docs/deployment/set-up-telegram-bridge.md` | -| `nemoclaw-reference` | `docs/reference/architecture.md`, `docs/reference/commands.md`, `docs/reference/inference-profiles.md`, `docs/reference/network-policies.md`, `docs/reference/troubleshooting.md` | +| `nemoclaw-reference` | `docs/reference/architecture.md`, `docs/reference/commands.md`, `docs/reference/network-policies.md`, `docs/reference/troubleshooting.md` | ### Regenerating skills after doc changes -When you add, edit, or remove pages in `docs/`, regenerate the skills so agents stay in sync: +After changing any page in `docs/`, regenerate the skills from the repo root: ```bash -python scripts/docs-to-skills.py docs/ .agents/skills/docs/ --prefix nemoclaw +python scripts/docs-to-skills.py docs/ .agents/skills/ --prefix nemoclaw ``` -Always use this exact output path (`.agents/skills/docs/`) and prefix (`nemoclaw`) so skill names and locations stay consistent. +Always use this exact output path (`.agents/skills/`) and prefix (`nemoclaw`) so skill names and locations stay consistent. Preview what would change before writing files: ```bash -python scripts/docs-to-skills.py docs/ .agents/skills/docs/ --prefix nemoclaw --dry-run +python scripts/docs-to-skills.py docs/ .agents/skills/ --prefix nemoclaw --dry-run ``` -The generated `SKILL.md` files are committed to the repo but are entirely autogenerated. Do not edit any file under `.agents/skills/docs/` — edit the source page in `docs/` and re-run the script instead. The one exception is the `## Gotchas` section at the bottom of each `SKILL.md`, which is reserved for project-specific notes you add manually. +Other useful flags: -### How the script works +| Flag | Purpose | +|------|---------| +| `--strategy ` | Grouping strategy: `smart` (default), `grouped`, or `individual`. | +| `--name-map CAT=NAME` | Override a generated skill name (e.g. `--name-map about=overview`). | +| `--exclude ` | Skip specific files (e.g. `--exclude "release-notes.md"`). | + +### How the Script Works The script reads YAML frontmatter from each doc page to determine its content type (`how_to`, `concept`, `reference`, `get_started`), then groups pages into skills using the `smart` strategy by default. -Procedure pages (`how_to`, `get_started`) become the main body of the skill. Concept pages become a `## Context` section. Reference pages go into a `references/` subdirectory for progressive disclosure, keeping the `SKILL.md` concise (under 500 lines). +Procedure pages (`how_to`, `get_started`) become the main body of the skill. +Concept pages become a `## Context` section. +Reference pages go into a `references/` subdirectory for progressive disclosure, keeping the `SKILL.md` concise (under 500 lines). -Cross-references between doc pages are rewritten as skill-to-skill pointers so agents can navigate between skills. MyST/Sphinx directives are converted to standard markdown. +Cross-references between doc pages are rewritten as skill-to-skill pointers so agents can navigate between skills. +MyST/Sphinx directives are converted to standard markdown. For full usage details and all flags, see the docstring at the top of `scripts/docs-to-skills.py`. -## When to Update Docs - -Update documentation when your change: - -- Adds, removes, or renames a CLI command or flag. -- Changes default behavior or configuration. -- Adds a new feature that users interact with. -- Fixes a bug that the docs describe incorrectly. -- Changes an API, protocol, or policy schema. - ## Building Docs Locally Verify the docs are built correctly by building them and checking the output. @@ -88,7 +103,7 @@ make docs-live ### Format - Docs use [MyST Markdown](https://myst-parser.readthedocs.io/), a Sphinx-compatible superset of CommonMark. -- Every page starts with YAML frontmatter (title, description, topics, tags, content type). +- Every page starts with YAML frontmatter (title, description.main, description.agent, topics, tags, content type). - Include the SPDX license header after frontmatter: ```html @@ -105,7 +120,9 @@ make docs-live title: page: "NemoClaw Page Title — Subtitle with Context" nav: "Short Nav Title" -description: "One-sentence summary of the page." +description: + main: "One-sentence summary for readers, SEO, and doc search snippets." + agent: "Third-person verb summary for agent routing. Add 'Use when...' with trigger phrases." keywords: ["primary keyword", "secondary keyword phrase"] topics: ["generative_ai", "ai_agents"] tags: ["openclaw", "openshell", "relevant", "tags"] diff --git a/docs/_ext/json_output/core/json_formatter.py b/docs/_ext/json_output/core/json_formatter.py index 250451a31..463aeac36 100644 --- a/docs/_ext/json_output/core/json_formatter.py +++ b/docs/_ext/json_output/core/json_formatter.py @@ -32,6 +32,24 @@ logger = logging.getLogger(__name__) +def _normalize_description_fields(metadata: dict[str, Any]) -> tuple[str, str]: + """Parse description from frontmatter: nested main/agent or legacy flat string.""" + raw = metadata.get("description") + if isinstance(raw, dict): + main = str(raw.get("main") or "").strip() + agent = str(raw.get("agent") or "").strip() + if main and not agent: + agent = main + elif agent and not main: + main = agent + return main, agent + if raw is not None and not isinstance(raw, dict): + text = str(raw).strip() + if text: + return text, text + return "", "" + + class JSONFormatter: """Handles JSON data structure building and formatting.""" @@ -56,9 +74,12 @@ def add_metadata_fields(self, data: dict[str, Any], metadata: dict[str, Any]) -> New schema: topics, tags, industry, content.type, content.learning_level, content.audience, facets.modality Legacy schema: categories, personas, difficulty, content_type, modality """ - # Basic metadata fields - if metadata.get("description"): - data["description"] = metadata["description"] + # Basic metadata fields — nested description.main / description.agent or legacy string + main_desc, agent_desc = _normalize_description_fields(metadata) + if main_desc: + data["description"] = main_desc + if agent_desc and agent_desc != main_desc: + data["description_agent"] = agent_desc # Tags (same in both schemas) if metadata.get("tags"): diff --git a/docs/_ext/search_assets/modules/DocumentLoader.js b/docs/_ext/search_assets/modules/DocumentLoader.js index a15e55c1f..53ed30427 100644 --- a/docs/_ext/search_assets/modules/DocumentLoader.js +++ b/docs/_ext/search_assets/modules/DocumentLoader.js @@ -105,6 +105,7 @@ class DocumentLoader { title: this.sanitizeText(doc.title, 200), // Add description as separate indexed field (for improved search relevance) description: this.sanitizeText(doc.description, 300), + description_agent: this.sanitizeText(doc.description_agent, 300), content: this.sanitizeText(doc.content, 5000), summary: this.sanitizeText(doc.summary, 500), headings: this.sanitizeHeadings(doc.headings), diff --git a/docs/_ext/search_assets/modules/SearchEngine.js b/docs/_ext/search_assets/modules/SearchEngine.js index dfe736b3b..a1a5bea34 100644 --- a/docs/_ext/search_assets/modules/SearchEngine.js +++ b/docs/_ext/search_assets/modules/SearchEngine.js @@ -188,7 +188,8 @@ class SearchEngine { // Primary fields - highest relevance this.field('title', { boost: 10 }); // Title matches most important - this.field('description', { boost: 8 }); // Frontmatter description (hand-crafted) + this.field('description', { boost: 8 }); // Frontmatter description.main (hand-crafted) + this.field('description_agent', { boost: 6 }); // description.agent for agent-oriented phrasing // Secondary fields - structural relevance this.field('keywords', { boost: 7 }); // Explicit keywords @@ -215,7 +216,10 @@ class SearchEngine { this.add({ id: doc.id, title: doc.title || '', - description: doc.description || '', // NEW: separate indexed field + description: doc.description || '', + description_agent: doc.description_agent && doc.description_agent !== doc.description + ? doc.description_agent + : '', content: (doc.content || '').substring(0, 5000), // Limit content length summary: doc.summary || '', headings: self.extractHeadingsText(doc.headings), @@ -641,19 +645,21 @@ class SearchEngine { * Calculate boost for description matches */ calculateDescriptionBoost(doc, queryTerms) { - if (!doc.description) return 0; + const texts = [...new Set( + [doc.description, doc.description_agent].filter(Boolean) + )]; + if (texts.length === 0) return 0; - const descLower = doc.description.toLowerCase(); let boost = 0; - - // Check if query terms appear early in description - queryTerms.forEach(term => { - const pos = descLower.indexOf(term); - if (pos !== -1) { - // Boost more if term appears early - boost += pos < 50 ? 1 : 0.5; - } - }); + for (const text of texts) { + const descLower = text.toLowerCase(); + queryTerms.forEach(term => { + const pos = descLower.indexOf(term); + if (pos !== -1) { + boost += pos < 50 ? 1 : 0.5; + } + }); + } return boost; } diff --git a/docs/_includes/alpha-statement.md b/docs/_includes/alpha-statement.md deleted file mode 100644 index ed927d642..000000000 --- a/docs/_includes/alpha-statement.md +++ /dev/null @@ -1,7 +0,0 @@ - -:::{admonition} Alpha software -NemoClaw is in alpha, available as an early preview since March 16, 2026. -APIs, configuration schemas, and runtime behavior are subject to breaking changes between releases. -Do not use this software in production environments. -File issues and feedback through the GitHub repository as the project continues to stabilize. -::: diff --git a/docs/about/how-it-works.md b/docs/about/how-it-works.md index 1c566983c..2cdb77ca8 100644 --- a/docs/about/how-it-works.md +++ b/docs/about/how-it-works.md @@ -2,7 +2,9 @@ title: page: "How NemoClaw Works — Plugin, Blueprint, and Sandbox Lifecycle" nav: "How It Works" -description: "Learn how NemoClaw combines a lightweight CLI plugin with a versioned blueprint to move OpenClaw into a controlled sandbox." +description: + main: "Learn how NemoClaw combines a lightweight CLI plugin with a versioned blueprint to move OpenClaw into a controlled sandbox." + agent: "Describes how NemoClaw combines a CLI plugin with a versioned blueprint to move OpenClaw into a controlled sandbox. Use when explaining the sandbox lifecycle, blueprint architecture, or how NemoClaw layers on top of OpenShell." keywords: ["how nemoclaw works", "nemoclaw sandbox lifecycle blueprint"] topics: ["generative_ai", "ai_agents"] tags: ["openclaw", "openshell", "sandboxing", "inference_routing", "blueprints", "network_policy"] @@ -28,6 +30,17 @@ This page explains the key concepts about NemoClaw at a high level. The `nemoclaw` CLI is the primary entrypoint for setting up and managing sandboxed OpenClaw agents. It delegates heavy lifting to a versioned blueprint, a Python artifact that orchestrates sandbox creation, policy application, and inference provider setup through the OpenShell CLI. +NemoClaw adds the following layers on top of OpenShell. + +| Layer | What it provides | +|-------|------------------| +| Onboarding | Guided setup that validates credentials, selects providers, and creates a working sandbox in one command. | +| Blueprint | A hardened Dockerfile with security policies, capability drops, and least-privilege network rules. | +| State management | Safe migration of agent state across machines with credential stripping and integrity verification. | +| Messaging bridges | Host-side processes that connect Telegram, Discord, and Slack to the sandboxed agent. | + +OpenShell handles *how* to sandbox an agent securely. NemoClaw handles *what* goes in the sandbox and makes the setup accessible. For the full system diagram, see [Architecture](../reference/architecture.md). + ```{mermaid} flowchart TB subgraph Host @@ -116,20 +129,23 @@ OpenShell intercepts every inference call and routes it to the configured provid During onboarding, NemoClaw validates the selected provider and model, configures the OpenShell route, and bakes the matching model reference into the sandbox image. The sandbox then talks to `inference.local`, while the host owns the actual provider credential and upstream endpoint. -## Network and Filesystem Policy +## Protection Layers + +The sandbox starts with a default policy that controls network egress, filesystem access, process privileges, and inference routing. -The sandbox starts with a default policy defined in `openclaw-sandbox.yaml`. -This policy controls which network endpoints the agent can reach and which filesystem paths it can access. +| Layer | What it protects | When it applies | +|---|---|---| +| Network | Blocks unauthorized outbound connections. | Hot-reloadable at runtime. | +| Filesystem | Prevents reads and writes outside `/sandbox` and `/tmp`. | Locked at sandbox creation. | +| Process | Blocks privilege escalation and dangerous syscalls. | Locked at sandbox creation. | +| Inference | Reroutes model API calls to controlled backends. | Hot-reloadable at runtime. | -- For network, only endpoints listed in the policy are allowed. - When the agent tries to reach an unlisted host, OpenShell blocks the request and surfaces it in the TUI for operator approval. -- For filesystem, the agent can write to `/sandbox` and `/tmp`. - All other system paths are read-only. +When the agent tries to reach an unlisted host, OpenShell blocks the request and surfaces it in the TUI for operator approval. Approved endpoints persist for the current session but are not saved to the baseline policy file. -Approved endpoints persist for the current session but are not saved to the baseline policy file. +For details on the baseline rules, refer to [Network Policies](../reference/network-policies.md). For container-level hardening, refer to [Sandbox Hardening](../deployment/sandbox-hardening.md). ## Next Steps - Follow the [Quickstart](../get-started/quickstart.md) to launch your first sandbox. - Refer to the [Architecture](../reference/architecture.md) for the full technical structure, including file layouts and the blueprint lifecycle. -- Refer to [Inference Profiles](../reference/inference-profiles.md) for detailed provider configuration. +- Refer to [Inference Options](../inference/inference-options.md) for detailed provider configuration. diff --git a/docs/about/overview.md b/docs/about/overview.md index f6f732744..6f4f253d4 100644 --- a/docs/about/overview.md +++ b/docs/about/overview.md @@ -2,7 +2,9 @@ title: page: "NemoClaw Overview — What It Does and How It Fits Together" nav: "Overview" -description: "NemoClaw is an open source reference stack that simplifies running OpenClaw always-on assistants safely." +description: + main: "NemoClaw is an open source reference stack that simplifies running OpenClaw always-on assistants safely." + agent: "Explains what NemoClaw does and how it fits with OpenClaw and OpenShell. Use when asking what NemoClaw is, how it works at a high level, or what the project provides." keywords: ["nemoclaw overview", "openclaw always-on assistants", "nvidia openshell", "nvidia nemotron"] topics: ["generative_ai", "ai_agents"] tags: ["openclaw", "openshell", "sandboxing", "inference_routing", "blueprints"] @@ -20,9 +22,6 @@ status: published # Overview -```{include} ../_includes/alpha-statement.md -``` - NVIDIA NemoClaw is an open source reference stack that simplifies running [OpenClaw](https://openclaw.ai) always-on assistants. It incorporates policy-based privacy and security guardrails, giving users control over their agents’ behavior and data handling. This enables self-evolving claws to run more safely in clouds, on prem, RTX PCs and DGX Spark. @@ -36,6 +35,19 @@ By combining powerful open source models with built-in safety measures, NemoClaw | Route inference | Configures OpenShell inference routing so agent traffic flows through cloud-hosted Nemotron 3 Super 120B via [build.nvidia.com](https://build.nvidia.com). | | Manage the lifecycle | Handles blueprint versioning, digest verification, and sandbox setup. | +## Key Features + +NemoClaw provides the following capabilities on top of the OpenShell runtime. + +| Feature | Description | +|---------|-------------| +| Guided onboarding | Validates credentials, selects providers, and creates a working sandbox in one command. | +| Hardened blueprint | A security-first Dockerfile with capability drops, least-privilege network rules, and declarative policy. | +| State management | Safe migration of agent state across machines with credential stripping and integrity verification. | +| Messaging bridges | Host-side processes that connect Telegram, Discord, and Slack to the sandboxed agent. | +| Routed inference | Provider-routed model calls through the OpenShell gateway, transparent to the agent. Supports NVIDIA Endpoints, OpenAI, Anthropic, Google Gemini, and local Ollama. | +| Layered protection | Network, filesystem, process, and inference controls that can be hot-reloaded or locked at creation. | + ## Challenge Autonomous AI agents like OpenClaw can make arbitrary network requests, access the host filesystem, and call any inference endpoint. Without guardrails, this creates security, cost, and compliance risks that grow as agents run unattended. diff --git a/docs/about/release-notes.md b/docs/about/release-notes.md index b5f3b88e1..c46bc5a06 100644 --- a/docs/about/release-notes.md +++ b/docs/about/release-notes.md @@ -2,7 +2,9 @@ title: page: "NemoClaw Release Notes" nav: "Release Notes" -description: "Changelog and feature history for NemoClaw releases." +description: + main: "Changelog and feature history for NemoClaw releases." + agent: "Lists changelogs and feature history for NemoClaw releases. Use when checking what changed in a release, looking up version history, or reviewing the changelog." keywords: ["nemoclaw release notes", "nemoclaw changelog"] topics: ["generative_ai", "ai_agents"] tags: ["nemoclaw", "releases"] @@ -20,9 +22,6 @@ status: published # Release Notes -```{include} ../_includes/alpha-statement.md -``` - NVIDIA NemoClaw is available in early preview starting March 16, 2026. Use the following GitHub resources to track changes. | Resource | Description | diff --git a/docs/conf.py b/docs/conf.py index 210c21649..8f31ea730 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -26,8 +26,13 @@ "sphinxcontrib.mermaid", "json_output", "search_assets", + "sphinx_reredirects", ] +redirects = { + "reference/inference-profiles": "../inference/inference-options.html", +} + autodoc_default_options = { "members": True, "undoc-members": False, @@ -95,6 +100,10 @@ html_theme_options = { # "public_docs_features": True, # TODO: Uncomment this when the docs are public + "announcement": ( + "🔔 NVIDIA NemoClaw is alpha software. APIs and behavior" + " may change without notice. Do not use in production." + ), "icon_links": [ { "name": "NemoClaw GitHub", diff --git a/docs/deployment/deploy-to-remote-gpu.md b/docs/deployment/deploy-to-remote-gpu.md index 6fcf603dc..34a945393 100644 --- a/docs/deployment/deploy-to-remote-gpu.md +++ b/docs/deployment/deploy-to-remote-gpu.md @@ -2,7 +2,9 @@ title: page: "Deploy NemoClaw to a Remote GPU Instance with Brev" nav: "Deploy to Remote GPU" -description: "Provision a remote GPU VM with NemoClaw using Brev deployment." +description: + main: "Provision a remote GPU VM with NemoClaw using Brev deployment." + agent: "Provisions a remote GPU VM with NemoClaw using Brev deployment. Use when deploying to a cloud GPU, setting up a remote NemoClaw instance, or configuring Brev." keywords: ["deploy nemoclaw remote gpu", "nemoclaw brev cloud deployment"] topics: ["generative_ai", "ai_agents"] tags: ["openclaw", "openshell", "deployment", "gpu", "nemoclaw"] @@ -23,6 +25,19 @@ status: published Run NemoClaw on a remote GPU instance through [Brev](https://brev.nvidia.com). The deploy command provisions the VM, installs dependencies, and connects you to a running sandbox. +## Quick Start + +If your Brev instance is already up and you want to try NemoClaw immediately, start with the sandbox chat flow: + +```console +$ nemoclaw my-assistant connect +$ openclaw tui +``` + +This gets you into the sandbox shell first and opens the OpenClaw chat UI right away. + +If you are connecting from your local machine and still need to provision the remote VM, use `nemoclaw deploy ` as described below. + ## Prerequisites - The [Brev CLI](https://brev.nvidia.com) installed and authenticated. @@ -47,7 +62,7 @@ The deploy script performs the following steps on the VM: 1. Installs Docker and the NVIDIA Container Toolkit if a GPU is present. 2. Installs the OpenShell CLI. -3. Runs the nemoclaw setup to create the gateway, register providers, and launch the sandbox. +3. Runs `nemoclaw onboard` (the setup wizard) to create the gateway, register providers, and launch the sandbox. 4. Starts auxiliary services, such as the Telegram bridge and cloudflared tunnel. ## Connect to the Remote Sandbox @@ -75,6 +90,28 @@ Run a test agent prompt inside the remote sandbox: $ openclaw agent --agent main --local -m "Hello from the remote sandbox" --session-id test ``` +## Remote Dashboard Access + +The NemoClaw dashboard validates the browser origin against an allowlist baked +into the sandbox image at build time. By default the allowlist only contains +`http://127.0.0.1:18789`. When accessing the dashboard from a remote browser +(for example through a Brev public URL or an SSH port-forward), set +`CHAT_UI_URL` to the origin the browser will use **before** running setup: + +```console +$ export CHAT_UI_URL="https://openclaw0-.brevlab.com" +$ nemoclaw deploy +``` + +For SSH port-forwarding, the origin is typically `http://127.0.0.1:18789` (the +default), so no extra configuration is needed. + +:::{note} +On Brev, set `CHAT_UI_URL` in the launchable environment configuration so it is +available when the setup script builds the sandbox image. If `CHAT_UI_URL` is +not set on a headless host, `brev-setup.sh` prints a warning. +::: + ## GPU Configuration The deploy script uses the `NEMOCLAW_GPU` environment variable to select the GPU type. diff --git a/docs/deployment/sandbox-hardening.md b/docs/deployment/sandbox-hardening.md new file mode 100644 index 000000000..57ab30a15 --- /dev/null +++ b/docs/deployment/sandbox-hardening.md @@ -0,0 +1,90 @@ +--- +title: + page: "Sandbox Image Hardening" + nav: "Sandbox Hardening" +description: + main: "Security hardening measures applied to the NemoClaw sandbox container image." + agent: "Describes security hardening measures applied to the NemoClaw sandbox container image. Use when reviewing container security, Docker capabilities, process limits, or sandbox hardening controls." +keywords: ["nemoclaw sandbox hardening", "container security", "docker capabilities", "process limits"] +topics: ["generative_ai", "ai_agents"] +tags: ["nemoclaw", "sandboxing", "security"] +content: + type: reference + difficulty: technical_beginner + audience: ["developer", "engineer"] +status: published +--- + + + +# Sandbox Image Hardening + +The NemoClaw sandbox image applies several security measures to reduce attack +surface and limit the blast radius of untrusted workloads. + +## Removed Unnecessary Tools + +Build toolchains (`gcc`, `g++`, `make`) and network probes (`netcat`) are +explicitly purged from the runtime image. These tools are not needed at runtime +and would unnecessarily widen the attack surface. + +If you need a compiler during build, use the existing multi-stage build +(the `builder` stage has full Node.js tooling) and copy only artifacts into the +runtime stage. + +## Process Limits + +The container ENTRYPOINT sets `ulimit -u 512` to cap the number of processes +a sandbox user can spawn. This mitigates fork-bomb attacks. The startup script +(`nemoclaw-start.sh`) applies the same limit. + +Adjust the value via the `--ulimit nproc=512:512` flag if launching with +`docker run` directly. + +## Dropping Linux Capabilities + +When running the sandbox container, drop all Linux capabilities and re-add only +what is strictly required: + +```console +$ docker run --rm \ + --cap-drop=ALL \ + --ulimit nproc=512:512 \ + nemoclaw-sandbox +``` + +### Docker Compose Example + +```yaml +services: + nemoclaw-sandbox: + image: nemoclaw-sandbox:latest + cap_drop: + - ALL + cap_add: + - NET_BIND_SERVICE + ulimits: + nproc: + soft: 512 + hard: 512 + security_opt: + - no-new-privileges:true + read_only: true + tmpfs: + - /tmp:size=64m +``` + +> **Note:** The `Dockerfile` itself cannot enforce `--cap-drop` — that is a +> runtime concern controlled by the container orchestrator. Always configure +> capability dropping in your `docker run` flags, Compose file, or Kubernetes +> `securityContext`. + +## References + +- [#807](https://github.com/NVIDIA/NemoClaw/issues/807) — gcc in sandbox image +- [#808](https://github.com/NVIDIA/NemoClaw/issues/808) — netcat in sandbox image +- [#809](https://github.com/NVIDIA/NemoClaw/issues/809) — No process limit +- [#797](https://github.com/NVIDIA/NemoClaw/issues/797) — Drop Linux capabilities diff --git a/docs/deployment/set-up-telegram-bridge.md b/docs/deployment/set-up-telegram-bridge.md index 5d85cb83f..9581c93ba 100644 --- a/docs/deployment/set-up-telegram-bridge.md +++ b/docs/deployment/set-up-telegram-bridge.md @@ -2,7 +2,9 @@ title: page: "Set Up the NemoClaw Telegram Bridge for Remote Agent Chat" nav: "Set Up Telegram Bridge" -description: "Forward messages between Telegram and the sandboxed OpenClaw agent." +description: + main: "Forward messages between Telegram and the sandboxed OpenClaw agent." + agent: "Forwards messages between Telegram and the sandboxed OpenClaw agent. Use when setting up a Telegram bot bridge, connecting a chat interface, or configuring Telegram integration." keywords: ["nemoclaw telegram bridge", "telegram bot openclaw agent"] topics: ["generative_ai", "ai_agents"] tags: ["openclaw", "openshell", "telegram", "deployment", "nemoclaw"] diff --git a/docs/get-started/quickstart.md b/docs/get-started/quickstart.md index 58774132f..182120b73 100644 --- a/docs/get-started/quickstart.md +++ b/docs/get-started/quickstart.md @@ -2,7 +2,9 @@ title: page: "NemoClaw Quickstart — Install, Launch, and Run Your First Agent" nav: "Quickstart" -description: "Install NemoClaw, launch a sandbox, and run your first agent prompt." +description: + main: "Install NemoClaw, launch a sandbox, and run your first agent prompt." + agent: "Installs NemoClaw, launches a sandbox, and runs the first agent prompt. Use when onboarding, installing, or launching a NemoClaw sandbox for the first time." keywords: ["nemoclaw quickstart", "install nemoclaw openclaw sandbox"] topics: ["generative_ai", "ai_agents"] tags: ["openclaw", "openshell", "sandboxing", "inference_routing", "nemoclaw"] @@ -20,20 +22,116 @@ status: published # Quickstart -```{include} ../_includes/alpha-statement.md -``` +:::{admonition} Alpha software +NemoClaw is in alpha, available as an early preview since March 16, 2026. +APIs, configuration schemas, and runtime behavior are subject to breaking changes between releases. +Do not use this software in production environments. +File issues and feedback through the GitHub repository as the project continues to stabilize. +::: Follow these steps to get started with NemoClaw and your first sandboxed OpenClaw agent. +## Prerequisites + +Before getting started, check the prerequisites to ensure you have the necessary software and hardware to run NemoClaw. + +### Hardware + +| Resource | Minimum | Recommended | +|----------|----------------|------------------| +| CPU | 4 vCPU | 4+ vCPU | +| RAM | 8 GB | 16 GB | +| Disk | 20 GB free | 40 GB free | + +The sandbox image is approximately 2.4 GB compressed. During image push, the Docker daemon, k3s, and the OpenShell gateway run alongside the export pipeline, which buffers decompressed layers in memory. On machines with less than 8 GB of RAM, this combined usage can trigger the OOM killer. If you cannot add memory, configuring at least 8 GB of swap can work around the issue at the cost of slower performance. + +### Software + +| Dependency | Version | +|------------|----------------------------------| +| Linux | Ubuntu 22.04 LTS or later | +| Node.js | 22.16 or later | +| npm | 10 or later | +| Container runtime | Supported runtime installed and running | +| [OpenShell](https://github.com/NVIDIA/OpenShell) | Installed | + +### Container Runtimes + +| Platform | Supported runtimes | Notes | +|----------|--------------------|-------| +| Linux | Docker | Primary supported path. | +| macOS (Apple Silicon) | Colima, Docker Desktop | Install Xcode Command Line Tools (`xcode-select --install`) and start the runtime before running the installer. | +| macOS (Intel) | Podman | Not supported yet. Depends on OpenShell support for Podman on macOS. | +| Windows WSL | Docker Desktop (WSL backend) | Supported target path. | +| DGX Spark | Docker | Refer to the [DGX Spark setup guide](https://github.com/NVIDIA/NemoClaw/blob/main/spark-install.md) for cgroup v2 and Docker configuration. | + +## Install NemoClaw and Onboard OpenClaw Agent + +Download and run the installer script. +The script installs Node.js if it is not already present, then runs the guided onboard wizard to create a sandbox, configure inference, and apply security policies. + :::{note} -NemoClaw currently requires a fresh installation of OpenClaw. +NemoClaw creates a fresh OpenClaw instance inside the sandbox during the onboarding process. ::: -```{include} ../../README.md -:start-after: -:end-before: +```bash +curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash +``` + +If you use nvm or fnm to manage Node.js, the installer may not update your current shell's PATH. +If `nemoclaw` is not found after install, run `source ~/.bashrc` (or `source ~/.zshrc` for zsh) or open a new terminal. + +When the install completes, a summary confirms the running environment: + +```text +────────────────────────────────────────────────── +Sandbox my-assistant (Landlock + seccomp + netns) +Model nvidia/nemotron-3-super-120b-a12b (NVIDIA Endpoints) +────────────────────────────────────────────────── +Run: nemoclaw my-assistant connect +Status: nemoclaw my-assistant status +Logs: nemoclaw my-assistant logs --follow +────────────────────────────────────────────────── + +[INFO] === Installation complete === +``` + +## Chat with the Agent + +Connect to the sandbox, then chat with the agent through the TUI or the CLI. + +```bash +nemoclaw my-assistant connect ``` +In the sandbox shell, open the OpenClaw terminal UI and start a chat: + +```bash +openclaw tui +``` + +Alternatively, send a single message and print the response: + +```bash +openclaw agent --agent main --local -m "hello" --session-id test +``` + +## Uninstall + +To remove NemoClaw and all resources created during setup, run the uninstall script: + +```bash +curl -fsSL https://raw.githubusercontent.com/NVIDIA/NemoClaw/refs/heads/main/uninstall.sh | bash +``` + +| Flag | Effect | +|--------------------|-----------------------------------------------------| +| `--yes` | Skip the confirmation prompt. | +| `--keep-openshell` | Leave the `openshell` binary installed. | +| `--delete-models` | Also remove NemoClaw-pulled Ollama models. | + +For troubleshooting installation or onboarding issues, see the [Troubleshooting guide](../reference/troubleshooting.md). + ## Next Steps - [Switch inference providers](../inference/switch-inference-providers.md) to use a different model or endpoint. diff --git a/docs/index.md b/docs/index.md index d4a411124..f9f613026 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,7 +2,9 @@ title: page: "NVIDIA NemoClaw Developer Guide" nav: "NemoClaw" -description: "NemoClaw is an open source reference stack that simplifies running OpenClaw always-on assistants more safely, with a single command." +description: + main: "NemoClaw is an open source reference stack that simplifies running OpenClaw always-on assistants more safely, with a single command." + agent: "Provides an open source reference stack that simplifies running OpenClaw always-on assistants more safely. Use when setting up NemoClaw, exploring the project, or looking for the landing page." keywords: ["nemoclaw open source reference stack", "openclaw always-on assistants", "nvidia openshell", "nvidia nemotron"] topics: ["generative_ai", "ai_agents"] tags: ["openclaw", "openshell", "sandboxing", "inference_routing", "nemoclaw"] @@ -25,9 +27,6 @@ status: published :end-before: ``` -```{include} _includes/alpha-statement.md -``` - NVIDIA NemoClaw is an open source reference stack that simplifies running [OpenClaw](https://openclaw.ai) always-on assistants more safely. It installs the [NVIDIA OpenShell](https://github.com/NVIDIA/OpenShell) runtime, part of NVIDIA Agent Toolkit, an environment designed for executing claws with additional security, and open source models like [NVIDIA Nemotron](https://build.nvidia.com). @@ -126,14 +125,14 @@ CLI commands for launching, connecting, monitoring, and managing sandboxes. {bdg-secondary}`Reference` ::: -:::{grid-item-card} Inference Profiles -:link: reference/inference-profiles +:::{grid-item-card} Inference Options +:link: inference/inference-options :link-type: doc -NVIDIA endpoint inference configuration and available models. +Providers available during onboarding and how inference routing works. +++ -{bdg-secondary}`Reference` +{bdg-secondary}`Concept` ::: :::{grid-item-card} How It Works @@ -176,6 +175,16 @@ Understand agent identity, memory, and configuration files that persist in the s {bdg-secondary}`Concept` ::: +:::{grid-item-card} Security Best Practices +:link: security/best-practices +:link-type: doc + +Controls reference, risk framework, and posture profiles for sandbox security. + ++++ +{bdg-secondary}`Concept` +::: + :::{grid-item-card} How-To Guides :link: inference/switch-inference-providers :link-type: doc @@ -188,6 +197,14 @@ Task-oriented guides for inference, deployment, and policy management. :::: +--- + +```{admonition} Notice and Disclaimer +:class: warning + +This software automatically retrieves, accesses or interacts with external materials. Those retrieved materials are not distributed with this software and are governed solely by separate terms, conditions and licenses. You are solely responsible for finding, reviewing and complying with all applicable terms, conditions, and licenses, and for verifying the security, integrity and suitability of any retrieved materials for your specific use case. This software is provided "AS IS", without warranty of any kind. The author makes no representations or warranties regarding any retrieved materials, and assumes no liability for any losses, damages, liabilities or legal consequences from your use or inability to use this software or any retrieved materials. Use this software and the retrieved materials at your own risk. +``` + ```{toctree} :hidden: @@ -214,6 +231,8 @@ Quickstart :caption: Inference :hidden: +Inference Options +Use Local Inference Switch Inference Providers ``` @@ -225,12 +244,20 @@ Approve or Deny Network Requests Customize the Network Policy ``` +```{toctree} +:caption: Security +:hidden: + +Security Best Practices +``` + ```{toctree} :caption: Deployment :hidden: Deploy to a Remote GPU Instance Set Up the Telegram Bridge +Sandbox Hardening ``` ```{toctree} @@ -254,7 +281,6 @@ Back Up and Restore Architecture Commands -Inference Profiles Network Policies Troubleshooting ``` @@ -263,6 +289,7 @@ Troubleshooting :caption: Resources :hidden: +Report Vulnerabilities resources/license Discord ``` diff --git a/docs/inference/inference-options.md b/docs/inference/inference-options.md new file mode 100644 index 000000000..f2486d280 --- /dev/null +++ b/docs/inference/inference-options.md @@ -0,0 +1,80 @@ +--- +title: + page: "NemoClaw Inference Options" + nav: "Inference Options" +description: + main: "Inference providers available during NemoClaw onboarding and how the routed inference model works." + agent: "Lists all inference providers offered during NemoClaw onboarding. Use when explaining which providers are available, what the onboard wizard presents, or how inference routing works." +keywords: ["nemoclaw inference options", "nemoclaw onboarding providers", "nemoclaw inference routing"] +topics: ["generative_ai", "ai_agents"] +tags: ["openclaw", "openshell", "inference_routing", "nemoclaw"] +content: + type: concept + difficulty: technical_beginner + audience: ["developer", "engineer"] +status: published +--- + + + +# Inference Options + +NemoClaw supports multiple inference providers. +During onboarding, the `nemoclaw onboard` wizard presents a numbered list of providers to choose from. +Your selection determines where the agent's inference traffic is routed. + +## How Inference Routing Works + +The agent inside the sandbox talks to `inference.local`. +It never connects to a provider directly. +OpenShell intercepts inference traffic on the host and forwards it to the provider you selected. + +Provider credentials stay on the host. +The sandbox does not receive your API key. + +## Provider Options + +The onboard wizard presents the following provider options by default. +The first six are always available. +Ollama appears when it is installed or running on the host. + +| Option | Description | Curated models | +|--------|-------------|----------------| +| NVIDIA Endpoints | Routes to models hosted on [build.nvidia.com](https://build.nvidia.com). You can also enter any model ID from the catalog. Set `NVIDIA_API_KEY`. | Nemotron 3 Super 120B, Kimi K2.5, GLM-5, MiniMax M2.5, GPT-OSS 120B | +| OpenAI | Routes to the OpenAI API. Set `OPENAI_API_KEY`. | `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, `gpt-5.4-pro-2026-03-05` | +| Other OpenAI-compatible endpoint | Routes to any server that implements `/v1/chat/completions`. The wizard prompts for a base URL and model name. Works with OpenRouter, LocalAI, llama.cpp, or any compatible proxy. Set `COMPATIBLE_API_KEY`. | You provide the model name. | +| Anthropic | Routes to the Anthropic Messages API. Set `ANTHROPIC_API_KEY`. | `claude-sonnet-4-6`, `claude-haiku-4-5`, `claude-opus-4-6` | +| Other Anthropic-compatible endpoint | Routes to any server that implements the Anthropic Messages API (`/v1/messages`). The wizard prompts for a base URL and model name. Set `COMPATIBLE_ANTHROPIC_API_KEY`. | You provide the model name. | +| Google Gemini | Routes to Google's OpenAI-compatible endpoint. Set `GEMINI_API_KEY`. | `gemini-3.1-pro-preview`, `gemini-3.1-flash-lite-preview`, `gemini-3-flash-preview`, `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.5-flash-lite` | +| Local Ollama | Routes to a local Ollama instance on `localhost:11434`. NemoClaw detects installed models, offers starter models if none are present, pulls and warms the selected model, and validates it. | Selected during onboarding. For more information, refer to [Use a Local Inference Server](use-local-inference.md). | + +## Experimental Options + +The following local inference options require `NEMOCLAW_EXPERIMENTAL=1` and, when prerequisites are met, appear in the onboarding selection list. + +| Option | Condition | Notes | +|--------|-----------|-------| +| Local NVIDIA NIM | NIM-capable GPU detected | Pulls and manages a NIM container. | +| Local vLLM | vLLM running on `localhost:8000` | Auto-detects the loaded model. | + +For setup instructions, refer to [Use a Local Inference Server](use-local-inference.md). + +## Validation + +NemoClaw validates the selected provider and model before creating the sandbox. +If validation fails, the wizard returns to provider selection. + +| Provider type | Validation method | +|---|---| +| OpenAI-compatible | Tries `/responses` first, then `/chat/completions`. | +| Anthropic-compatible | Tries `/v1/messages`. | +| NVIDIA Endpoints (manual model entry) | Validates the model name against the catalog API. | +| Compatible endpoints | Sends a real inference request because many proxies do not expose a `/models` endpoint. | + +## Next Steps + +- [Use a Local Inference Server](use-local-inference.md) for Ollama, vLLM, NIM, and compatible-endpoint setup details. +- [Switch Inference Models](switch-inference-providers.md) for changing the model at runtime without re-onboarding. diff --git a/docs/inference/switch-inference-providers.md b/docs/inference/switch-inference-providers.md index 89ed8aef2..60f058cee 100644 --- a/docs/inference/switch-inference-providers.md +++ b/docs/inference/switch-inference-providers.md @@ -2,7 +2,9 @@ title: page: "Switch NemoClaw Inference Models at Runtime" nav: "Switch Inference Models" -description: "Change the active inference model without restarting the sandbox." +description: + main: "Change the active inference model without restarting the sandbox." + agent: "Changes the active inference model without restarting the sandbox. Use when switching inference providers, changing the model runtime, or reconfiguring inference routing." keywords: ["switch nemoclaw inference model", "change inference runtime"] topics: ["generative_ai", "ai_agents"] tags: ["openclaw", "openshell", "inference_routing"] @@ -95,4 +97,4 @@ The output includes the active provider, model, and endpoint. ## Related Topics -- [Inference Profiles](../reference/inference-profiles.md) for full profile configuration details. +- [Inference Options](inference-options.md) for the full list of providers available during onboarding. diff --git a/docs/inference/use-local-inference.md b/docs/inference/use-local-inference.md new file mode 100644 index 000000000..f4c0acba1 --- /dev/null +++ b/docs/inference/use-local-inference.md @@ -0,0 +1,231 @@ +--- +title: + page: "Use a Local Inference Server with NemoClaw" + nav: "Use Local Inference" +description: + main: "Connect NemoClaw to a local model server such as Ollama, vLLM, TensorRT-LLM, or any OpenAI-compatible endpoint." + agent: "Connects NemoClaw to a local inference server. Use when setting up Ollama, vLLM, TensorRT-LLM, NIM, or any OpenAI-compatible local model server with NemoClaw." +keywords: ["nemoclaw local inference", "ollama nemoclaw", "vllm nemoclaw", "local model server", "openai compatible endpoint"] +topics: ["generative_ai", "ai_agents"] +tags: ["openclaw", "openshell", "inference_routing", "local_inference"] +content: + type: how_to + difficulty: intermediate + audience: ["developer", "engineer"] +status: published +--- + + + +# Use a Local Inference Server + +NemoClaw can route inference to a model server running on your machine instead of a cloud API. +This page covers Ollama, compatible-endpoint paths for other servers, and two experimental options for vLLM and NVIDIA NIM. + +All approaches use the same `inference.local` routing model. +The agent inside the sandbox never connects to your model server directly. +OpenShell intercepts inference traffic and forwards it to the local endpoint you configure. + +## Prerequisites + +- NemoClaw installed. + Refer to the [Quickstart](../get-started/quickstart.md) if you have not installed yet. +- A local model server running, or Ollama installed. The NemoClaw onboard wizard can also start Ollama for you. + +## Ollama + +Ollama is the default local inference option. +The onboard wizard detects Ollama automatically when it is installed or running on the host. + +If Ollama is not running, NemoClaw starts it for you. +On macOS, the wizard also offers to install Ollama through Homebrew if it is not present. + +Run the onboard wizard. + +```console +$ nemoclaw onboard +``` + +Select **Local Ollama** from the provider list. +NemoClaw lists installed models or offers starter models if none are installed. +It pulls the selected model, loads it into memory, and validates it before continuing. + +### Linux with Docker + +On Linux hosts that run NemoClaw with Docker, the sandbox reaches Ollama through +`http://host.openshell.internal:11434`, not the host shell's `localhost` socket. +If Ollama is already running, make sure it listens on `0.0.0.0:11434` instead of +`127.0.0.1:11434`. + +```console +$ OLLAMA_HOST=0.0.0.0:11434 ollama serve +``` + +If Ollama only binds loopback, NemoClaw can detect it on the host, but the +sandbox-side validation step fails because containers cannot reach it. + +### Non-Interactive Setup + +```console +$ NEMOCLAW_PROVIDER=ollama \ + NEMOCLAW_MODEL=qwen2.5:14b \ + nemoclaw onboard --non-interactive +``` + +If `NEMOCLAW_MODEL` is not set, NemoClaw selects a default model based on available memory. + +| Variable | Purpose | +|---|---| +| `NEMOCLAW_PROVIDER` | Set to `ollama`. | +| `NEMOCLAW_MODEL` | Ollama model tag to use. Optional. | + +## OpenAI-Compatible Server + +This option works with any server that implements `/v1/chat/completions`, including vLLM, TensorRT-LLM, llama.cpp, LocalAI, and others. + +Start your model server. +The examples below use vLLM, but any OpenAI-compatible server works. + +```console +$ vllm serve meta-llama/Llama-3.1-8B-Instruct --port 8000 +``` + +Run the onboard wizard. + +```console +$ nemoclaw onboard +``` + +When the wizard asks you to choose an inference provider, select **Other OpenAI-compatible endpoint**. +Enter the base URL of your local server, for example `http://localhost:8000/v1`. + +The wizard prompts for an API key. +If your server does not require authentication, enter any non-empty string (for example, `dummy`). + +NemoClaw validates the endpoint by sending a test inference request before continuing. + +### Non-Interactive Setup + +Set the following environment variables for scripted or CI/CD deployments. + +```console +$ NEMOCLAW_PROVIDER=custom \ + NEMOCLAW_ENDPOINT_URL=http://localhost:8000/v1 \ + NEMOCLAW_MODEL=meta-llama/Llama-3.1-8B-Instruct \ + COMPATIBLE_API_KEY=dummy \ + nemoclaw onboard --non-interactive +``` + +| Variable | Purpose | +|---|---| +| `NEMOCLAW_PROVIDER` | Set to `custom` for an OpenAI-compatible endpoint. | +| `NEMOCLAW_ENDPOINT_URL` | Base URL of the local server. | +| `NEMOCLAW_MODEL` | Model ID as reported by the server. | +| `COMPATIBLE_API_KEY` | API key for the endpoint. Use any non-empty value if authentication is not required. | + +## Anthropic-Compatible Server + +If your local server implements the Anthropic Messages API (`/v1/messages`), choose **Other Anthropic-compatible endpoint** during onboarding instead. + +```console +$ nemoclaw onboard +``` + +For non-interactive setup, use `NEMOCLAW_PROVIDER=anthropicCompatible` and set `COMPATIBLE_ANTHROPIC_API_KEY`. + +```console +$ NEMOCLAW_PROVIDER=anthropicCompatible \ + NEMOCLAW_ENDPOINT_URL=http://localhost:8080 \ + NEMOCLAW_MODEL=my-model \ + COMPATIBLE_ANTHROPIC_API_KEY=dummy \ + nemoclaw onboard --non-interactive +``` + +## vLLM Auto-Detection (Experimental) + +When vLLM is already running on `localhost:8000`, NemoClaw can detect it automatically and query the `/v1/models` endpoint to determine the loaded model. + +Set the experimental flag and run onboard. + +```console +$ NEMOCLAW_EXPERIMENTAL=1 nemoclaw onboard +``` + +Select **Local vLLM [experimental]** from the provider list. +NemoClaw detects the running model and validates the endpoint. + +:::{note} +NemoClaw forces the `chat/completions` API path for vLLM. +The vLLM `/v1/responses` endpoint does not run the `--tool-call-parser`, so tool calls arrive as raw text. +::: + +### Non-Interactive Setup + +```console +$ NEMOCLAW_EXPERIMENTAL=1 \ + NEMOCLAW_PROVIDER=vllm \ + nemoclaw onboard --non-interactive +``` + +NemoClaw auto-detects the model from the running vLLM instance. +To override the model, set `NEMOCLAW_MODEL`. + +## NVIDIA NIM (Experimental) + +NemoClaw can pull, start, and manage a NIM container on hosts with a NIM-capable NVIDIA GPU. + +Set the experimental flag and run onboard. + +```console +$ NEMOCLAW_EXPERIMENTAL=1 nemoclaw onboard +``` + +Select **Local NVIDIA NIM [experimental]** from the provider list. +NemoClaw filters available models by GPU VRAM, pulls the NIM container image, starts it, and waits for it to become healthy before continuing. + +:::{note} +NIM uses vLLM internally. +The same `chat/completions` API path restriction applies. +::: + +### Non-Interactive Setup + +```console +$ NEMOCLAW_EXPERIMENTAL=1 \ + NEMOCLAW_PROVIDER=nim \ + nemoclaw onboard --non-interactive +``` + +To select a specific model, set `NEMOCLAW_MODEL`. + +## Verify the Configuration + +After onboarding completes, confirm the active provider and model. + +```console +$ nemoclaw status +``` + +The output shows the provider label (for example, "Local vLLM" or "Other OpenAI-compatible endpoint") and the active model. + +## Switch Models at Runtime + +You can change the model without re-running onboard. +Refer to [Switch Inference Models](switch-inference-providers.md) for the full procedure. + +For compatible endpoints, the command is: + +```console +$ openshell inference set --provider compatible-endpoint --model +``` + +If the provider itself needs to change (for example, switching from vLLM to a cloud API), rerun `nemoclaw onboard`. + +## Next Steps + +- [Inference Options](inference-options.md) for the full list of providers available during onboarding. +- [Switch Inference Models](switch-inference-providers.md) for runtime model switching. +- [Quickstart](../get-started/quickstart.md) for first-time installation. diff --git a/docs/monitoring/monitor-sandbox-activity.md b/docs/monitoring/monitor-sandbox-activity.md index 8812c653f..413237af5 100644 --- a/docs/monitoring/monitor-sandbox-activity.md +++ b/docs/monitoring/monitor-sandbox-activity.md @@ -2,7 +2,9 @@ title: page: "Monitor NemoClaw Sandbox Activity and Debug Issues" nav: "Monitor Sandbox Activity" -description: "Inspect sandbox health, trace agent behavior, and diagnose problems." +description: + main: "Inspect sandbox health, trace agent behavior, and diagnose problems." + agent: "Inspects sandbox health, traces agent behavior, and diagnoses problems. Use when monitoring a running sandbox, debugging agent issues, or checking sandbox logs." keywords: ["monitor nemoclaw sandbox", "debug nemoclaw agent issues"] topics: ["generative_ai", "ai_agents"] tags: ["openclaw", "openshell", "monitoring", "troubleshooting", "nemoclaw"] @@ -41,7 +43,8 @@ Key fields in the output include the following: - Blueprint run ID, which is the identifier for the most recent blueprint execution. - Inference provider, which shows the active provider, model, and endpoint. -Run `nemoclaw status` on the host to check sandbox state. Use `openshell sandbox list` for the underlying sandbox details. +Run `nemoclaw status` on the host to check sandbox state. +Use `openshell sandbox list` for the underlying sandbox details. ## View Blueprint and Sandbox Logs @@ -54,7 +57,7 @@ $ nemoclaw logs To follow the log output in real time: ```console -$ nemoclaw logs -f +$ nemoclaw logs --follow ``` ## Monitor Network Activity in the TUI @@ -87,7 +90,7 @@ $ openclaw agent --agent main --local -m "Test inference" --session-id debug If the request fails, check the following: 1. Run `nemoclaw status` to confirm the active provider and endpoint. -2. Run `nemoclaw logs -f` to view error messages from the blueprint runner. +2. Run `nemoclaw logs --follow` to view error messages from the blueprint runner. 3. Verify that the inference endpoint is reachable from the host. ## Related Topics diff --git a/docs/network-policy/approve-network-requests.md b/docs/network-policy/approve-network-requests.md index 4d334ef9f..57ed29908 100644 --- a/docs/network-policy/approve-network-requests.md +++ b/docs/network-policy/approve-network-requests.md @@ -2,7 +2,9 @@ title: page: "Approve or Deny NemoClaw Agent Network Requests" nav: "Approve Network Requests" -description: "Review and approve blocked agent network requests in the TUI." +description: + main: "Review and approve blocked agent network requests in the TUI." + agent: "Reviews and approves blocked agent network requests in the TUI. Use when approving or denying sandbox egress requests, managing blocked network calls, or using the approval TUI." keywords: ["nemoclaw approve network requests", "sandbox egress approval tui"] topics: ["generative_ai", "ai_agents"] tags: ["openclaw", "openshell", "network_policy", "security", "nemoclaw"] diff --git a/docs/network-policy/customize-network-policy.md b/docs/network-policy/customize-network-policy.md index 1749f0606..6da052d3c 100644 --- a/docs/network-policy/customize-network-policy.md +++ b/docs/network-policy/customize-network-policy.md @@ -2,7 +2,9 @@ title: page: "Customize the NemoClaw Sandbox Network Policy" nav: "Customize Network Policy" -description: "Add, remove, or modify allowed endpoints in the sandbox policy." +description: + main: "Add, remove, or modify allowed endpoints in the sandbox policy." + agent: "Adds, removes, or modifies allowed endpoints in the sandbox policy. Use when customizing network policy, changing egress rules, or configuring sandbox endpoint access." keywords: ["customize nemoclaw network policy", "sandbox egress policy configuration"] topics: ["generative_ai", "ai_agents"] tags: ["openclaw", "openshell", "network_policy", "security", "nemoclaw"] diff --git a/docs/reference/architecture.md b/docs/reference/architecture.md index 3924ea16d..cdbef3071 100644 --- a/docs/reference/architecture.md +++ b/docs/reference/architecture.md @@ -2,7 +2,9 @@ title: page: "NemoClaw Architecture — Plugin, Blueprint, and Sandbox Structure" nav: "Architecture" -description: "Learn how NemoClaw combines a lightweight CLI plugin with a versioned blueprint to move OpenClaw into a controlled sandbox." +description: + main: "Learn how NemoClaw combines a lightweight CLI plugin with a versioned blueprint to move OpenClaw into a controlled sandbox." + agent: "Describes how NemoClaw combines a CLI plugin with a versioned blueprint to move OpenClaw into a controlled sandbox. Use when looking up NemoClaw architecture, plugin structure, or blueprint design." keywords: ["nemoclaw architecture", "nemoclaw plugin blueprint structure"] topics: ["generative_ai", "ai_agents"] tags: ["openclaw", "openshell", "sandboxing", "blueprints", "inference_routing"] @@ -22,6 +24,67 @@ status: published NemoClaw has two main components: a TypeScript plugin that integrates with the OpenClaw CLI, and a Python blueprint that orchestrates OpenShell resources. +## System Overview + +NVIDIA OpenShell is a general-purpose agent runtime. It provides sandbox containers, a credential-storing gateway, inference proxying, and policy enforcement, but has no opinions about what runs inside. NemoClaw is an opinionated reference stack built on OpenShell that handles what goes in the sandbox and makes the setup accessible. + +```{mermaid} +graph LR + classDef nemoclaw fill:#76b900,stroke:#5a8f00,color:#fff,stroke-width:2px,font-weight:bold + classDef openshell fill:#1a1a1a,stroke:#1a1a1a,color:#fff,stroke-width:2px,font-weight:bold + classDef sandbox fill:#444,stroke:#76b900,color:#fff,stroke-width:2px,font-weight:bold + classDef agent fill:#f5f5f5,stroke:#e0e0e0,color:#1a1a1a,stroke-width:1px + classDef external fill:#f5f5f5,stroke:#e0e0e0,color:#1a1a1a,stroke-width:1px + classDef user fill:#fff,stroke:#76b900,color:#1a1a1a,stroke-width:2px,font-weight:bold + + USER(["👤 User"]):::user + + subgraph EXTERNAL["External Services"] + INFERENCE["Inference Provider
NVIDIA Endpoints · OpenAI
Anthropic · Ollama · vLLM
"]:::external + MSGAPI["Messaging Platforms
Telegram · Discord · Slack"]:::external + INTERNET["Internet
PyPI · npm · GitHub · APIs"]:::external + end + + subgraph HOST["Host Machine"] + + subgraph NEMOCLAW["NemoClaw"] + direction TB + NCLI["CLI + Onboarding
Guided setup · provider selection
credential validation · deploy
"]:::nemoclaw + BRIDGE["Messaging Bridges
Connect chat platforms
to sandboxed agent
"]:::nemoclaw + BP["Blueprint
Hardened Dockerfile
Network policies · Presets
Security configuration
"]:::nemoclaw + MIGRATE["State Management
Migration snapshots
Credential stripping
Integrity verification
"]:::nemoclaw + end + + subgraph OPENSHELL["OpenShell"] + direction TB + GW["Gateway
Credential store
Inference proxy
Policy engine
Device auth
"]:::openshell + OSCLI["openshell CLI
provider · sandbox
gateway · policy
"]:::openshell + + subgraph SANDBOX["Sandbox Container 🔒"] + direction TB + AGENT["Agent
OpenClaw or any
compatible agent
"]:::agent + PLUG["NemoClaw Plugin
Extends agent with
managed configuration
"]:::sandbox + end + end + end + + USER -->|"nemoclaw onboard
nemoclaw connect"| NCLI + USER -->|"Chat messages"| MSGAPI + + NCLI -->|"Orchestrates"| OSCLI + BP -->|"Defines sandbox
shape + policies"| SANDBOX + MIGRATE -->|"Safe state
transfer"| SANDBOX + + AGENT -->|"Inference requests
no credentials"| GW + GW -->|"Proxied with
credential injected"| INFERENCE + + MSGAPI -->|"Bot messages"| BRIDGE + BRIDGE -->|"Relayed as data
via SSH"| AGENT + + AGENT -.->|"Policy-gated"| INTERNET + GW -.->|"Enforced by
gateway"| INTERNET +``` + ## NemoClaw Plugin The plugin is a thin TypeScript package that registers an inference provider and the `/nemoclaw` slash command. @@ -107,4 +170,24 @@ OpenShell intercepts them and routes to the configured provider: Agent (sandbox) ──▶ OpenShell gateway ──▶ NVIDIA Endpoint (build.nvidia.com) ``` -Refer to [Inference Profiles](../reference/inference-profiles.md) for provider configuration details. +Refer to [Inference Options](../inference/inference-options.md) for provider configuration details. + +## Host-Side State and Config + +NemoClaw keeps its operator-facing state on the host rather than inside the sandbox. + +| Path | Purpose | +|---|---| +| `~/.nemoclaw/credentials.json` | Provider credentials saved during onboarding. | +| `~/.nemoclaw/sandboxes.json` | Registered sandbox metadata, including the default sandbox selection. | +| `~/.openclaw/openclaw.json` | Host OpenClaw configuration that NemoClaw snapshots or restores during migration flows. | + +The following environment variables configure optional services and local access. + +| Variable | Purpose | +|---|---| +| `TELEGRAM_BOT_TOKEN` | Bot token for the Telegram bridge. | +| `ALLOWED_CHAT_IDS` | Comma-separated list of Telegram chat IDs allowed to message the agent. | +| `CHAT_UI_URL` | URL for the optional chat UI endpoint. | + +For normal setup and reconfiguration, prefer `nemoclaw onboard` over editing these files by hand. diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 82f92405f..dae816471 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -2,7 +2,9 @@ title: page: "NemoClaw CLI Commands Reference" nav: "Commands" -description: "Full CLI reference for plugin and standalone NemoClaw commands." +description: + main: "Full CLI reference for slash commands and standalone NemoClaw commands." + agent: "Lists all slash commands and standalone NemoClaw CLI commands. Use when looking up a command, checking command syntax, or browsing the CLI reference." keywords: ["nemoclaw cli commands", "nemoclaw command reference"] topics: ["generative_ai", "ai_agents"] tags: ["openclaw", "openshell", "nemoclaw", "cli"] @@ -28,15 +30,35 @@ The `/nemoclaw` slash command is available inside the OpenClaw chat interface fo | Subcommand | Description | |---|---| +| `/nemoclaw` | Show slash-command help and host CLI pointers | | `/nemoclaw status` | Show sandbox and inference state | +| `/nemoclaw onboard` | Show onboarding status and reconfiguration guidance | +| `/nemoclaw eject` | Show rollback instructions for returning to the host installation | ## Standalone Host Commands The `nemoclaw` binary handles host-side operations that run outside the OpenClaw plugin context. +### `nemoclaw help`, `nemoclaw --help`, `nemoclaw -h` + +Show the top-level usage summary and command groups. +Running `nemoclaw` with no arguments shows the same help output. + +```console +$ nemoclaw help +``` + +### `nemoclaw --version`, `nemoclaw -v` + +Print the installed NemoClaw CLI version. + +```console +$ nemoclaw --version +``` + ### `nemoclaw onboard` -Run the interactive setup wizard. +Run the interactive setup wizard (recommended for new installs). The wizard creates an OpenShell gateway, registers inference providers, builds the sandbox image, and creates the sandbox. Use this command for new installs and for recreating a sandbox after changes to policy or configuration. @@ -47,6 +69,19 @@ $ nemoclaw onboard The wizard prompts for a provider first, then collects the provider credential if needed. Supported non-experimental choices include NVIDIA Endpoints, OpenAI, Anthropic, Google Gemini, and compatible OpenAI or Anthropic endpoints. Credentials are stored in `~/.nemoclaw/credentials.json`. +The legacy `nemoclaw setup` command is deprecated; use `nemoclaw onboard` instead. + +For non-interactive onboarding, you must explicitly accept the third-party software notice: + +```console +$ nemoclaw onboard --non-interactive --yes-i-accept-third-party-software +``` + +or: + +```console +$ NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 nemoclaw onboard --non-interactive +``` The wizard prompts for a sandbox name. Names must follow RFC 1123 subdomain rules: lowercase alphanumeric characters and hyphens only, and must start and end with an alphanumeric character. @@ -70,7 +105,7 @@ The `nemoclaw deploy` command is experimental and may not work as expected. ::: Deploy NemoClaw to a remote GPU instance through [Brev](https://brev.nvidia.com). -The deploy script installs Docker, NVIDIA Container Toolkit if a GPU is present, and OpenShell on the VM, then runs the nemoclaw setup and connects to the sandbox. +The deploy script installs Docker, NVIDIA Container Toolkit if a GPU is present, and OpenShell on the VM, then runs `nemoclaw onboard` and connects to the sandbox. ```console $ nemoclaw deploy @@ -180,3 +215,43 @@ After the fixes complete, the script prompts you to run `nemoclaw onboard` to co ```console $ sudo nemoclaw setup-spark ``` + +### `nemoclaw debug` + +Collect diagnostics for bug reports. +Gathers system info, Docker state, gateway logs, and sandbox status into a summary or tarball. +Use `--sandbox ` to target a specific sandbox, `--quick` for a smaller snapshot, or `--output ` to save a tarball that you can attach to an issue. + +```console +$ nemoclaw debug [--quick] [--sandbox NAME] [--output PATH] +``` + +| Flag | Description | +|------|-------------| +| `--quick` | Collect minimal diagnostics only | +| `--sandbox NAME` | Target a specific sandbox (default: auto-detect) | +| `--output PATH` | Write diagnostics tarball to the given path | + +### `nemoclaw uninstall` + +Run `uninstall.sh` to remove NemoClaw sandboxes, gateway resources, related images and containers, and local state. +The CLI uses the local `uninstall.sh` first and falls back to the hosted script if the local file is unavailable. + +| Flag | Effect | +|---|---| +| `--yes` | Skip the confirmation prompt | +| `--keep-openshell` | Leave the `openshell` binary installed | +| `--delete-models` | Also remove NemoClaw-pulled Ollama models | + +```console +$ nemoclaw uninstall [--yes] [--keep-openshell] [--delete-models] +``` + +### Legacy `nemoclaw setup` + +Deprecated. Use `nemoclaw onboard` instead. +Running `nemoclaw setup` now delegates directly to `nemoclaw onboard`. + +```console +$ nemoclaw setup +``` diff --git a/docs/reference/inference-profiles.md b/docs/reference/inference-profiles.md deleted file mode 100644 index f1c1a4f49..000000000 --- a/docs/reference/inference-profiles.md +++ /dev/null @@ -1,88 +0,0 @@ ---- -title: - page: "NemoClaw Inference Profiles" - nav: "Inference Profiles" -description: "Configuration reference for NemoClaw routed inference providers." -keywords: ["nemoclaw inference profiles", "nemoclaw provider routing"] -topics: ["generative_ai", "ai_agents"] -tags: ["openclaw", "openshell", "inference_routing", "llms"] -content: - type: reference - difficulty: intermediate - audience: ["developer", "engineer"] -status: published ---- - - - -# Inference Profiles - -NemoClaw configures inference through the OpenShell gateway. -The agent inside the sandbox talks to `inference.local`, and OpenShell routes that traffic to the provider you selected during onboarding. - -## Routed Provider Model - -NemoClaw keeps provider credentials on the host. -The sandbox does not receive your raw OpenAI, Anthropic, Gemini, or NVIDIA API key. - -At onboard time, NemoClaw configures: - -- an OpenShell provider -- an OpenShell inference route -- the baked OpenClaw model reference inside the sandbox - -That means the sandbox knows which model family to use, while OpenShell owns the actual provider credential and upstream endpoint. - -## Supported Providers - -The following non-experimental provider paths are available through `nemoclaw onboard`. - -| Provider | Endpoint Type | Notes | -|---|---|---| -| NVIDIA Endpoints | OpenAI-compatible | Hosted models on `integrate.api.nvidia.com` | -| OpenAI | Native OpenAI-compatible | Uses OpenAI model IDs | -| Other OpenAI-compatible endpoint | Custom OpenAI-compatible | For compatible proxies and gateways | -| Anthropic | Native Anthropic | Uses `anthropic-messages` | -| Other Anthropic-compatible endpoint | Custom Anthropic-compatible | For Claude proxies and compatible gateways | -| Google Gemini | OpenAI-compatible | Uses Google's OpenAI-compatible endpoint | - -## Validation During Onboarding - -NemoClaw validates the selected provider and model before it creates the sandbox. - -- OpenAI-compatible providers: - NemoClaw tries `/responses` first, then `/chat/completions`. -- Anthropic-compatible providers: - NemoClaw tries `/v1/messages`. -- NVIDIA Endpoints manual model entry: - NemoClaw also validates the model name against `https://integrate.api.nvidia.com/v1/models`. -- Compatible endpoint flows: - NemoClaw validates by sending a real inference request, because many proxies do not expose a reliable `/models` endpoint. - -If validation fails, the wizard does not continue to sandbox creation. - -## Local Ollama - -Local Ollama is available in the standard onboarding flow when Ollama is installed or running on the host. -It uses the same routed `inference.local` pattern, but the upstream runtime runs locally instead of in the cloud. - -Ollama gets additional onboarding help: - -- if no models are installed, NemoClaw offers starter models -- it pulls the selected model -- it warms the model -- it validates the model before continuing - -## Experimental Local Providers - -The following local providers require `NEMOCLAW_EXPERIMENTAL=1`: - -- Local NVIDIA NIM (requires a NIM-capable GPU) -- Local vLLM (must already be running on `localhost:8000`) - -## Runtime Switching - -For runtime switching guidance, refer to [Switch Inference Models](../inference/switch-inference-providers.md). diff --git a/docs/reference/network-policies.md b/docs/reference/network-policies.md index bfbe74e20..75a6476d1 100644 --- a/docs/reference/network-policies.md +++ b/docs/reference/network-policies.md @@ -2,7 +2,9 @@ title: page: "NemoClaw Network Policies — Baseline Rules and Operator Approval" nav: "Network Policies" -description: "Baseline network policy, filesystem rules, and operator approval flow." +description: + main: "Baseline network policy, filesystem rules, and operator approval flow." + agent: "Documents baseline network policy, filesystem rules, and operator approval flow. Use when reviewing default network policies, understanding egress controls, or looking up the approval flow." keywords: ["nemoclaw network policy", "sandbox egress control operator approval"] topics: ["generative_ai", "ai_agents"] tags: ["openclaw", "openshell", "sandboxing", "network_policy", "security"] @@ -72,13 +74,13 @@ The following endpoint groups are allowed by default: - GET, POST, PATCH, PUT, DELETE * - `clawhub` - - `clawhub.com:443` - - `/usr/local/bin/openclaw` + - `clawhub.ai:443` + - `/usr/local/bin/openclaw`, `/usr/local/bin/node` - GET, POST * - `openclaw_api` - `openclaw.ai:443` - - `/usr/local/bin/openclaw` + - `/usr/local/bin/openclaw`, `/usr/local/bin/node` - GET, POST * - `openclaw_docs` @@ -88,8 +90,8 @@ The following endpoint groups are allowed by default: * - `npm_registry` - `registry.npmjs.org:443` - - `/usr/local/bin/openclaw`, `/usr/local/bin/npm` - - GET only + - `/usr/local/bin/openclaw`, `/usr/local/bin/npm`, `/usr/local/bin/node` + - All methods, all paths * - `telegram` - `api.telegram.org:443` diff --git a/docs/reference/troubleshooting.md b/docs/reference/troubleshooting.md index aa654184a..676371528 100644 --- a/docs/reference/troubleshooting.md +++ b/docs/reference/troubleshooting.md @@ -2,7 +2,9 @@ title: page: "NemoClaw Troubleshooting Guide" nav: "Troubleshooting" -description: "Diagnose and resolve common NemoClaw installation, onboarding, and runtime issues." +description: + main: "Diagnose and resolve common NemoClaw installation, onboarding, and runtime issues." + agent: "Diagnoses and resolves common NemoClaw installation, onboarding, and runtime issues. Use when troubleshooting errors, debugging sandbox problems, or resolving setup failures." keywords: ["nemoclaw troubleshooting", "nemoclaw debug sandbox issues"] topics: ["generative_ai", "ai_agents"] tags: ["openclaw", "openshell", "troubleshooting", "nemoclaw"] @@ -47,23 +49,29 @@ If you see an unsupported platform error, verify that you are running on a suppo ### Node.js version is too old -NemoClaw requires Node.js 20 or later. +NemoClaw requires Node.js 22.16 or later. If the installer exits with a Node.js version error, check your current version: ```console $ node --version ``` -If the version is below 20, install a supported release. +If the version is below 22.16, install a supported release. If you use nvm, run: ```console -$ nvm install 20 -$ nvm use 20 +$ nvm install 22 +$ nvm use 22 ``` Then re-run the installer. +### Image push fails with out-of-memory errors + +The sandbox image is approximately 2.4 GB compressed. During image push, the Docker daemon, k3s, and the OpenShell gateway run alongside the export pipeline, which buffers decompressed layers in memory. On machines with less than 8 GB of RAM, this combined usage can trigger the OOM killer. + +If you cannot add memory, configure at least 8 GB of swap to work around the issue at the cost of slower performance. + ### Docker is not running The installer and onboard wizard require Docker to be running. @@ -75,6 +83,15 @@ $ sudo systemctl start docker On macOS with Docker Desktop, open the Docker Desktop application and wait for it to finish starting before retrying. +### macOS first-run failures + +The two most common first-run failures on macOS are missing developer tools and Docker connection errors. + +To avoid these issues, install the prerequisites in the following order before running the NemoClaw installer: + +1. Install Xcode Command Line Tools (`xcode-select --install`). These are needed by the installer and Node.js toolchain. +2. Install and start a supported container runtime (Docker Desktop or Colima). Without a running runtime, the installer cannot connect to Docker. + ### npm install fails with permission errors If `npm install` fails with an `EACCES` permission error, do not run npm with `sudo`. @@ -95,7 +112,7 @@ If another process is already bound to this port, onboarding fails. Identify the conflicting process, verify it is safe to stop, and terminate it: ```console -$ lsof -i :18789 +$ sudo lsof -i :18789 $ kill ``` @@ -141,8 +158,74 @@ If neither is found, verify that Colima is running: $ colima status ``` +### Sandbox creation killed by OOM (exit 137) + +On systems with 8 GB RAM or less and no swap configured, the sandbox image push can exhaust available memory and get killed by the Linux OOM killer (exit code 137). + +NemoClaw automatically detects low memory during onboarding and prompts to create a 4 GB swap file. +If this automatic step fails or you are using a custom setup flow, create swap manually before running `nemoclaw onboard`: + +```console +$ sudo dd if=/dev/zero of=/swapfile bs=1M count=4096 status=none +$ sudo chmod 600 /swapfile +$ sudo mkswap /swapfile +$ sudo swapon /swapfile +$ echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab +$ nemoclaw onboard +``` + ## Runtime +### Reconnect after a host reboot + +After a host reboot, the container runtime, OpenShell gateway, and sandbox may not be running. +Follow these steps to reconnect. + +1. Start the container runtime. + + - **Linux:** start Docker if it is not already running (`sudo systemctl start docker`) + - **macOS:** open Docker Desktop or start Colima (`colima start`) + +1. Check sandbox state. + + ```console + $ openshell sandbox list + ``` + + If the sandbox shows `Ready`, skip to step 4. + +1. Restart the gateway (if needed). + + If the sandbox is not listed or the command fails, restart the OpenShell gateway: + + ```console + $ openshell gateway start --name nemoclaw + ``` + + Wait a few seconds, then re-check with `openshell sandbox list`. + +1. Reconnect. + + ```console + $ nemoclaw connect + ``` + +1. Start auxiliary services (if needed). + + If you use the Telegram bridge or cloudflared tunnel, start them again: + + ```console + $ nemoclaw start + ``` + +:::{admonition} If the sandbox does not recover +:class: warning + +If the sandbox remains missing after restarting the gateway, run `nemoclaw onboard` to recreate it. +The wizard prompts for confirmation before destroying an existing sandbox. If you confirm, it **destroys and recreates** the sandbox — workspace files (SOUL.md, USER.md, IDENTITY.md, AGENTS.md, MEMORY.md, and daily memory notes) are lost. +Back up your workspace first by following the instructions at [Back Up and Restore](../workspace/backup-restore.md). +::: + ### Sandbox shows as stopped The sandbox may have been stopped or deleted. diff --git a/docs/resources/license.md b/docs/resources/license.md index 3c5f56691..0d0466709 100644 --- a/docs/resources/license.md +++ b/docs/resources/license.md @@ -2,7 +2,9 @@ title: page: "NemoClaw License — Apache 2.0" nav: "License" -description: "Apache 2.0 license for the NemoClaw project." +description: + main: "Apache 2.0 license for the NemoClaw project." + agent: "Contains the Apache 2.0 license for the NemoClaw project. Use when checking the project license or reviewing license terms." keywords: ["nemoclaw license", "nemoclaw apache 2.0"] topics: ["generative_ai"] tags: ["nemoclaw", "licensing"] diff --git a/docs/security/best-practices.md b/docs/security/best-practices.md new file mode 100644 index 000000000..6bc188659 --- /dev/null +++ b/docs/security/best-practices.md @@ -0,0 +1,509 @@ +--- +title: + page: "NemoClaw Security Best Practices — Controls, Risks, and Posture Profiles" + nav: "Security Best Practices" +description: + main: "A risk framework for every configurable security control in NemoClaw: defaults, what you can change, and what happens if you do." + agent: "Presents a risk framework for every configurable security control in NemoClaw. Use when evaluating security posture, reviewing sandbox security defaults, or assessing control trade-offs." +keywords: ["nemoclaw security best practices", "sandbox security controls risk framework"] +topics: ["generative_ai", "ai_agents"] +tags: ["openclaw", "openshell", "sandboxing", "security", "network_policy", "nemoclaw"] +content: + type: concept + difficulty: intermediate + audience: ["developer", "engineer", "security_engineer"] +status: published +--- + + + +# Security Best Practices + +NemoClaw ships with deny-by-default security controls across four layers: network, filesystem, process, and inference. +You can tune every control, but each change shifts the risk profile. +This page documents every configurable knob, its default, what it protects, the concrete risk of relaxing it, and a recommendation for common use cases. + +For background on how the layers fit together, refer to [How It Works](../about/how-it-works.md). + + + +## Protection Layers at a Glance + +NemoClaw enforces security at four layers. +NemoClaw locks some when it creates the sandbox and requires a restart to change them. +You can hot-reload others while the sandbox runs. + +The following diagram shows the default posture immediately after `nemoclaw onboard`, before you approve any endpoints or apply any presets. + +```{mermaid} +flowchart TB + subgraph HOST["Your Machine — default posture after nemoclaw onboard"] + direction TB + + YOU["👤 Operator"] + + subgraph NC["NemoClaw + OpenShell"] + direction TB + + subgraph SB["Sandbox — the agent's isolated world"] + direction LR + PROC["⚙️ Process Layer
Controls what the agent can execute"] + FS["📁 Filesystem Layer
Controls what the agent can read and write"] + AGENT["🤖 Agent"] + end + + subgraph GW["Gateway — the gatekeeper"] + direction LR + NET["🌐 Network Layer
Controls where the agent can connect"] + INF["🧠 Inference Layer
Controls which AI models the agent can use"] + end + end + end + + OUTSIDE["🌍 Outside World
Internet · AI Providers · APIs"] + + AGENT -- "all requests" --> GW + GW -- "approved only" --> OUTSIDE + YOU -. "approve / deny" .-> GW + + classDef agent fill:#76b900,stroke:#5a8f00,color:#fff,stroke-width:2px,font-weight:bold + classDef locked fill:#1a1a1a,stroke:#76b900,color:#fff,stroke-width:2px + classDef hot fill:#333,stroke:#76b900,color:#e6f2cc,stroke-width:2px + classDef external fill:#f5f5f5,stroke:#ccc,color:#1a1a1a,stroke-width:1px + classDef operator fill:#fff,stroke:#76b900,color:#1a1a1a,stroke-width:2px,font-weight:bold + + class AGENT agent + class PROC,FS locked + class NET,INF hot + class OUTSIDE external + class YOU operator + + style HOST fill:none,stroke:#76b900,stroke-width:2px,color:#1a1a1a + style NC fill:none,stroke:#76b900,stroke-width:1px,stroke-dasharray:5 5,color:#1a1a1a + style SB fill:#f5faed,stroke:#76b900,stroke-width:2px,color:#1a1a1a + style GW fill:#2a2a2a,stroke:#76b900,stroke-width:2px,color:#fff +``` + +:::{list-table} +:header-rows: 1 +:widths: 20 30 20 30 + +* - Layer + - What it protects + - Enforcement point + - Changeable at runtime + +* - Network + - Unauthorized outbound connections and data exfiltration. + - OpenShell gateway + - Yes. Use `openshell policy set` or operator approval. + +* - Filesystem + - System binary tampering, credential theft, config manipulation. + - Landlock LSM + container mounts + - No. Requires sandbox re-creation. + +* - Process + - Privilege escalation, fork bombs, syscall abuse. + - Container runtime (Docker/K8s `securityContext`) + - No. Requires sandbox re-creation. + +* - Inference + - Credential exposure, unauthorized model access, cost overruns. + - OpenShell gateway + - Yes. Use `openshell inference set`. + +::: + +## Network Controls + +NemoClaw controls which hosts, ports, and HTTP methods the sandbox can reach, and lets operators approve or deny requests in real time. + + + +### Deny-by-Default Egress + +The sandbox blocks all outbound connections unless you explicitly list the endpoint in the policy file `nemoclaw-blueprint/policies/openclaw-sandbox.yaml`. + +| Aspect | Detail | +|---|---| +| Default | All egress denied. Only endpoints in the baseline policy can receive traffic. | +| What you can change | Add endpoints to the policy file (static) or with `openshell policy set` (dynamic). | +| Risk if relaxed | Each allowed endpoint is a potential data exfiltration path. The agent can send workspace content, credentials, or conversation history to any reachable host. | +| Recommendation | Add only endpoints the agent needs for its task. Prefer operator approval for one-off requests over permanently widening the baseline. | + +### Binary-Scoped Endpoint Rules + +Each network policy entry restricts which executables can reach the endpoint using the `binaries` field. + +OpenShell identifies the calling binary by reading `/proc//exe` (the kernel-trusted executable path, not `argv[0]`), walking the process tree for ancestor binaries, and computing a SHA256 hash of each binary on first use. +If someone replaces a binary while the sandbox runs, the hash mismatch triggers an immediate deny. + +| Aspect | Detail | +|---|---| +| Default | Each endpoint restricts access to specific binaries. For example, only `/usr/bin/gh` and `/usr/bin/git` can reach `github.com`. Binary paths support glob patterns (`*` matches one path component, `**` matches recursively). | +| What you can change | Add binaries to an endpoint entry, or omit the `binaries` field to allow any executable. | +| Risk if relaxed | Removing binary restrictions lets any process in the sandbox reach the endpoint. An agent could use `curl`, `wget`, or a Python script to exfiltrate data to an allowed host, bypassing the intended usage pattern. | +| Recommendation | Always scope endpoints to the binaries that need them. If the agent needs a host from a new binary, add that binary explicitly rather than removing the restriction. | + +### Path-Scoped HTTP Rules + +Endpoint rules restrict allowed HTTP methods and URL paths. + +| Aspect | Detail | +|---|---| +| Default | Most endpoints allow GET and POST on `/**`. Some allow GET only (read-only), such as `docs.openclaw.ai`. | +| What you can change | Add methods (PUT, DELETE, PATCH) or restrict paths to specific prefixes. | +| Risk if relaxed | Allowing all methods on an API endpoint gives the agent write and delete access. For example, allowing DELETE on `api.github.com` lets the agent delete repositories. | +| Recommendation | Use GET-only rules for endpoints that the agent only reads. Add write methods only for endpoints where the agent must create or modify resources. Restrict paths to specific API routes when possible. | + +### L4-Only vs L7 Inspection (`protocol` Field) + +All sandbox egress goes through OpenShell's CONNECT proxy. +The `protocol` field on an endpoint controls whether the proxy also inspects individual HTTP requests inside the tunnel. + +| Aspect | Detail | +|---|---| +| Default | Endpoints without a `protocol` field use L4-only enforcement: the proxy checks host, port, and binary identity, then relays the TCP stream without inspecting payloads. Setting `protocol: rest` enables L7 inspection: the proxy auto-detects and terminates TLS, then evaluates each HTTP request's method and path against the endpoint's `rules` or `access` preset. | +| What you can change | Add `protocol: rest` to an endpoint to enable per-request HTTP inspection. Use the `access` preset (`full`, `read-only`, `read-write`) or explicit `rules` to control allowed methods and paths. | +| Risk if relaxed | L4-only endpoints (no `protocol` field) allow the agent to send any data through the tunnel after the initial connection is permitted. The proxy cannot see or filter the HTTP method, path, or body. The `access: full` preset with `protocol: rest` enables inspection but allows all methods and paths, so it does not restrict what the agent can do at the HTTP level. | +| Recommendation | Use `protocol: rest` with specific `rules` for REST APIs where you want method and path control. Use `protocol: rest` with `access: read-only` for read-only endpoints. Omit `protocol` only for non-HTTP protocols (WebSocket, gRPC streaming) or endpoints that do not need HTTP inspection. | + +### Operator Approval Flow + +When the agent reaches an unlisted endpoint, OpenShell blocks the request and prompts the operator in the TUI. + +| Aspect | Detail | +|---|---| +| Default | Enabled. The gateway blocks all unlisted endpoints and requires approval. | +| What you can change | The system merges approved endpoints into the sandbox's policy as a new durable revision. They persist across sandbox restarts within the same sandbox instance. However, when you destroy and recreate the sandbox (for example, by running `nemoclaw onboard`), the policy resets to the baseline defined in the blueprint. | +| Risk if relaxed | Approving an endpoint permanently widens the running sandbox's policy. If you approve a broad domain (such as a CDN that hosts arbitrary content), the agent can fetch anything from that domain until you destroy and recreate the sandbox. | +| Recommendation | Review each blocked request before approving. If you find yourself approving the same endpoint repeatedly, add it to the baseline policy with appropriate binary and path restrictions. To reset approved endpoints, destroy and recreate the sandbox. | + +### Policy Presets + +NemoClaw ships preset policy files in `nemoclaw-blueprint/policies/presets/` for common integrations. + +| Preset | What it enables | Key risk | +|---|---|---| +| `discord` | Discord REST API, WebSocket gateway, CDN. | CDN endpoint (`cdn.discordapp.com`) allows GET to any path. WebSocket uses `access: full` (no inspection). | +| `docker` | Docker Hub, NVIDIA container registry. | Allows pulling arbitrary container images into the sandbox. | +| `huggingface` | Hugging Face model registry. | Allows downloading arbitrary models and datasets. | +| `jira` | Atlassian Jira API. | Gives agent read/write access to project issues and comments. | +| `npm` | npm and Yarn registries. | Allows installing arbitrary npm packages, which may contain malicious code. | +| `outlook` | Microsoft 365, Outlook. | Gives agent access to email. | +| `pypi` | Python Package Index. | Allows installing arbitrary Python packages, which may contain malicious code. | +| `slack` | Slack API, Socket Mode, webhooks. | WebSocket uses `access: full`. Agent can post to any channel the bot token has access to. | +| `telegram` | Telegram Bot API. | Agent can send messages to any chat the bot token has access to. | + +**Recommendation:** Apply presets only when the agent's task requires the integration. Review the preset's YAML file before applying to understand the endpoints, methods, and binary restrictions it adds. + +## Filesystem Controls + +NemoClaw restricts which paths the agent can read and write, protecting system binaries, configuration files, and gateway credentials. + + + +### Read-Only System Paths + +The container mounts system directories read-only to prevent the agent from modifying binaries, libraries, or configuration files. + +| Aspect | Detail | +|---|---| +| Default | `/usr`, `/lib`, `/proc`, `/dev/urandom`, `/app`, `/etc`, `/var/log` are read-only. | +| What you can change | Add or remove paths in the `filesystem_policy.read_only` section of the policy file. | +| Risk if relaxed | Making `/usr` or `/lib` writable lets the agent replace system binaries (such as `curl` or `node`) with trojanized versions. Making `/etc` writable lets the agent modify DNS resolution, TLS trust stores, or user accounts. | +| Recommendation | Never make system paths writable. If the agent needs a writable location for generated files, use a subdirectory of `/sandbox`. | + +### Read-Only `.openclaw` Config + +The `/sandbox/.openclaw` directory contains the OpenClaw gateway configuration, including auth tokens and CORS settings. +The container mounts it read-only while writable agent state (plugins, agent data) lives in `/sandbox/.openclaw-data` through symlinks. + +Multiple defense layers protect this directory: + +- **DAC permissions.** Root owns the directory and `openclaw.json` with `chmod 444`, so the sandbox user cannot write to them. +- **Immutable flag.** The entrypoint applies `chattr +i` to the directory and all symlinks, preventing modification even if other controls fail. +- **Symlink validation.** At startup, the entrypoint verifies every symlink in `.openclaw` points to the expected `.openclaw-data` target. If any symlink points elsewhere, the container refuses to start. +- **Config integrity hash.** The build process pins a SHA256 hash of `openclaw.json`. The entrypoint verifies it at startup and refuses to start if the hash does not match. + +| Aspect | Detail | +|---|---| +| Default | The container mounts `/sandbox/.openclaw` as read-only, root-owned, immutable, and integrity-verified at startup. `/sandbox/.openclaw-data` remains writable. | +| What you can change | Move `/sandbox/.openclaw` from `read_only` to `read_write` in the policy file. | +| Risk if relaxed | A writable `.openclaw` directory lets the agent modify its own gateway config: disabling CORS, changing auth tokens, or redirecting inference to an attacker-controlled endpoint. This is the single most dangerous filesystem change. | +| Recommendation | Never make `/sandbox/.openclaw` writable. | + +### Writable Paths + +The agent has read-write access to `/sandbox`, `/tmp`, and `/dev/null`. + +| Aspect | Detail | +|---|---| +| Default | `/sandbox` (agent workspace), `/tmp` (temporary files), `/dev/null`. | +| What you can change | Add additional writable paths in `filesystem_policy.read_write`. | +| Risk if relaxed | Each additional writable path expands the agent's ability to persist data and potentially modify system behavior. Adding `/var` lets the agent write to log directories. Adding `/home` gives access to other user directories. | +| Recommendation | Keep writable paths to `/sandbox` and `/tmp`. If the agent needs a persistent working directory, create a subdirectory under `/sandbox`. | + +### Landlock LSM Enforcement + +Landlock is a Linux Security Module that enforces filesystem access rules at the kernel level. + +| Aspect | Detail | +|---|---| +| Default | `compatibility: best_effort`. The entrypoint applies Landlock rules when the kernel supports them and silently skips them on older kernels. | +| What you can change | This is a NemoClaw default, not a user-facing knob. | +| Risk if relaxed | On kernels without Landlock support (pre-5.13), filesystem restrictions rely solely on container mount configuration, which is less granular. | +| Recommendation | Run on a kernel that supports Landlock (5.13+). Ubuntu 22.04 LTS and later include Landlock support. | + +## Process Controls + +NemoClaw limits the capabilities, user privileges, and resource quotas available to processes inside the sandbox. + + + +### Capability Drops + +The entrypoint drops dangerous Linux capabilities from the bounding set at startup using `capsh`. +This limits what capabilities any child process (gateway, sandbox, agent) can ever acquire. + +The entrypoint drops these capabilities: `cap_net_raw`, `cap_dac_override`, `cap_sys_chroot`, `cap_fsetid`, `cap_setfcap`, `cap_mknod`, `cap_audit_write`, `cap_net_bind_service`. +The entrypoint keeps these because it needs them for privilege separation using gosu: `cap_chown`, `cap_setuid`, `cap_setgid`, `cap_fowner`, `cap_kill`. + +This is best-effort: if `capsh` is not available or `CAP_SETPCAP` is not in the bounding set, the entrypoint logs a warning and continues with the default capability set. +For additional protection, pass `--cap-drop=ALL` with `docker run` or Compose (see [Sandbox Hardening](../deployment/sandbox-hardening.md)). + +| Aspect | Detail | +|---|---| +| Default | The entrypoint drops dangerous capabilities at startup using `capsh`. Best-effort. | +| What you can change | When launching with `docker run` directly, pass `--cap-drop=ALL --cap-add=NET_BIND_SERVICE` for stricter enforcement. In the standard NemoClaw flow (with `nemoclaw onboard`), the entrypoint handles capability dropping automatically. | +| Risk if relaxed | `CAP_NET_RAW` allows raw socket access for network sniffing. `CAP_DAC_OVERRIDE` bypasses filesystem permission checks. Attackers can use `CAP_SYS_CHROOT` in container escape chains. If `capsh` is unavailable, the container runs with the default Docker capability set. | +| Recommendation | Run on an image that includes `capsh` (the NemoClaw image includes it through `libcap2-bin`). For defense-in-depth, also pass `--cap-drop=ALL` at the container runtime level. | + +### Gateway Process Isolation + +The OpenClaw gateway runs as a separate `gateway` user, not as the `sandbox` user that runs the agent. + +| Aspect | Detail | +|---|---| +| Default | The entrypoint starts the gateway process using `gosu gateway`, isolating it from the agent's `sandbox` user. | +| What you can change | This is not a user-facing knob. The entrypoint enforces it when running as root. In non-root mode (when OpenShell sets `no-new-privileges`), gateway process isolation does not work because `gosu` cannot change users. | +| Risk if relaxed | If the gateway and agent run as the same user, the agent can kill the gateway process and restart it with a tampered configuration (the "fake-HOME" attack). | +| Recommendation | No action needed. The entrypoint handles this automatically. Be aware that non-root mode disables this isolation. | + +### No New Privileges + +The `no-new-privileges` flag prevents processes from gaining additional privileges through setuid binaries or capability inheritance. + +| Aspect | Detail | +|---|---| +| Default | OpenShell sets `PR_SET_NO_NEW_PRIVS` using `prctl()` inside the sandbox process as part of the seccomp filter setup. The NemoClaw Compose example also shows the equivalent `security_opt: no-new-privileges:true` setting. | +| What you can change | OpenShell's seccomp path enforces this inside the sandbox. It is not a user-facing knob. | +| Risk if relaxed | Without this flag, a compromised process could execute a setuid binary to escalate to root inside the container, then attempt container escape techniques. | +| Recommendation | No action needed. OpenShell enforces this automatically when the sandbox network policy is active. This flag prevents `gosu` from switching users, so non-root mode disables gateway process isolation in the NemoClaw entrypoint. | + +### Process Limit + +A process limit caps the number of processes the sandbox user can spawn. +The entrypoint sets both soft and hard limits using `ulimit -u 512`. +This is best-effort: if the container runtime restricts `ulimit` modification, the entrypoint logs a security warning and continues without the limit. + +| Aspect | Detail | +|---|---| +| Default | 512 processes (`ulimit -u 512`), best-effort. | +| What you can change | Increase or decrease the limit with `--ulimit nproc=N:N` in `docker run` or the `ulimits` section in Compose. The runtime-level ulimit takes precedence over the entrypoint's setting. | +| Risk if relaxed | Removing or raising the limit makes the sandbox vulnerable to fork-bomb attacks, where a runaway process spawns children until the host runs out of resources. If the entrypoint cannot set the limit (logs `[SECURITY] Could not set soft/hard nproc limit`), the container runs without process limits. | +| Recommendation | Keep the default at 512. If the agent runs workloads that spawn many child processes (such as parallel test runners), increase to 1024 and monitor host resource usage. If the entrypoint logs a warning about ulimit restrictions, set the limit through the container runtime instead. | + +### Non-Root User + +The sandbox runs agent processes as a dedicated `sandbox` user and group. +The entrypoint starts as root for privilege separation, then drops to the `sandbox` user for all agent commands. + +| Aspect | Detail | +|---|---| +| Default | `run_as_user: sandbox`, `run_as_group: sandbox`. A separate `gateway` user runs the gateway process. | +| What you can change | Change the `process` section in the policy file to run as a different user. | +| Risk if relaxed | Running as `root` inside the container gives the agent access to modify any file in the container filesystem and increases the impact of container escape vulnerabilities. | +| Recommendation | Never run as root. Keep the `sandbox` user. | + +### PATH Hardening + +The entrypoint locks the `PATH` environment variable to system directories, preventing the agent from injecting malicious binaries into command resolution. + +| Aspect | Detail | +|---|---| +| Default | The entrypoint sets `PATH` to `/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin` at startup. | +| What you can change | This is not a user-facing knob. The entrypoint enforces it. | +| Risk if relaxed | Without PATH hardening, the agent could create an executable named `curl` or `git` in a writable directory earlier in the PATH, intercepting commands run by the entrypoint or other processes. | +| Recommendation | No action needed. The entrypoint handles this automatically. | + +### Build Toolchain Removal + +The Dockerfile removes compilers and network probes from the runtime image. + +| Aspect | Detail | +|---|---| +| Default | The Dockerfile purges `gcc`, `gcc-12`, `g++`, `g++-12`, `cpp`, `cpp-12`, `make`, `netcat-openbsd`, `netcat-traditional`, and `ncat` from the sandbox image. | +| What you can change | Modify the Dockerfile to keep these tools, or install them at runtime if package manager access is allowed. | +| Risk if relaxed | A compiler lets the agent build arbitrary native code, including kernel exploits or custom network tools. `netcat` enables arbitrary TCP connections that bypass HTTP-level policy enforcement. | +| Recommendation | Keep build tools removed. If the agent needs to compile code, run the build in a separate, purpose-built container and copy artifacts into the sandbox. | + +## Gateway Authentication Controls + +The OpenClaw gateway authenticates devices that connect to the Control UI dashboard. +NemoClaw hardens these defaults at image build time. + +### Device Authentication + +Device authentication requires each connecting device to go through a pairing flow before it can interact with the gateway. + +| Aspect | Detail | +|---|---| +| Default | Enabled. The gateway requires device pairing for all connections. | +| What you can change | Set `NEMOCLAW_DISABLE_DEVICE_AUTH=1` as a Docker build argument to disable device authentication. This is a build-time setting baked into `openclaw.json` and verified by hash at startup. | +| Risk if relaxed | Disabling device auth allows any device on the network to connect to the gateway without proving identity. This is dangerous when combined with LAN-bind changes or cloudflared tunnels in remote deployments, resulting in an unauthenticated, publicly reachable dashboard. | +| Recommendation | Keep device auth enabled (the default). Only disable it for headless or development environments where no untrusted devices can reach the gateway. | + +### Insecure Auth Derivation + +The `allowInsecureAuth` setting controls whether the gateway permits non-HTTPS authentication. + +| Aspect | Detail | +|---|---| +| Default | Derived from the `CHAT_UI_URL` scheme at build time. When the URL uses `http://` (local development), insecure auth is allowed. When it uses `https://` (remote or production), insecure auth is blocked. | +| What you can change | This is derived automatically from `CHAT_UI_URL`. Set `CHAT_UI_URL` to an `https://` URL to enforce secure auth. | +| Risk if relaxed | Allowing insecure auth over HTTPS defeats the purpose of TLS, because authentication tokens transit in cleartext. | +| Recommendation | Use `https://` for any deployment accessible beyond `localhost`. The default local URL (`http://127.0.0.1:18789`) correctly allows insecure auth for local development. | + +### Auto-Pair Client Allowlist + +The auto-pair watcher automatically approves device pairing requests from recognized clients, so you do not need to manually approve the Control UI. + +| Aspect | Detail | +|---|---| +| Default | The watcher approves devices with `clientId` set to `openclaw-control-ui` or `clientMode` set to `webchat`. All other clients are rejected and logged. | +| What you can change | This is not a user-facing knob. The allowlist is defined in the entrypoint script. | +| Risk if relaxed | Approving all device types without validation lets rogue or unexpected clients pair with the gateway unchallenged. | +| Recommendation | No action needed. The entrypoint handles this automatically. If you see `[auto-pair] rejected unknown client=...` in the logs, investigate the source of the unexpected connection. | + +### CLI Secret Redaction + +The CLI automatically redacts secret patterns (API keys, bearer tokens, provider credentials) from command output and error messages before logging them. + +| Aspect | Detail | +|---|---| +| Default | Enabled. The runner redacts secrets from stdout, stderr, and thrown error messages. | +| What you can change | This is not a user-facing knob. The CLI enforces it on all command output paths. | +| Risk if relaxed | Without redaction, secrets could appear in terminal scrollback, log files, or debug output shared in bug reports. | +| Recommendation | No action needed. If you share `nemoclaw debug` output, verify that no secrets appear in the collected diagnostics. | + +## Inference Controls + +OpenShell routes all inference traffic through the gateway to isolate provider credentials from the sandbox. + +### Routed Inference through `inference.local` + +The OpenShell gateway intercepts all inference requests from the agent and routes them to the configured provider. +The agent never receives the provider API key. + +| Aspect | Detail | +|---|---| +| Default | The agent talks to `inference.local`. The host owns the credential and upstream endpoint. | +| What you can change | You cannot configure this architecture. The system always enforces it. | +| Risk if bypassed | If the agent could reach an inference endpoint directly (by adding it to the network policy), it would need an API key. Since the sandbox does not contain credentials, this acts as defense-in-depth. However, adding an inference provider's host to the network policy without going through OpenShell routing could let the agent use a stolen or hardcoded key. | +| Recommendation | Do not add inference provider hosts (such as `api.openai.com` or `api.anthropic.com`) to the network policy. Use OpenShell inference routing instead. | + +### Provider Trust Tiers + +Different inference providers have different trust and cost profiles. + +| Provider | Trust level | Cost risk | Data handling | +|---|---|---|---| +| NVIDIA Endpoints | High. Hosted on `build.nvidia.com`. | Pay-per-token with an API key. Unattended agents can accumulate cost. | NVIDIA infrastructure processes requests. | +| OpenAI | High. Commercial API. | Pay-per-token. Same cost risk as NVIDIA Endpoints. | Subject to OpenAI data policies. | +| Anthropic | High. Commercial API. | Pay-per-token. Same cost risk as NVIDIA Endpoints. | Subject to Anthropic data policies. | +| Google Gemini | High. Commercial API. | Pay-per-token. Same cost risk as NVIDIA Endpoints. | Subject to Google data policies. | +| Local Ollama | Self-hosted. No data leaves the machine. | No per-token cost. GPU/CPU resource cost. | Data stays local. | +| Custom compatible endpoint | Varies. Depends on the proxy or gateway. | Varies. | Depends on the endpoint operator. | + +**Recommendation:** For sensitive workloads, use local Ollama to keep data on-premise. For general use, NVIDIA Endpoints provide a good balance of capability and trust. Review the data policies of any cloud provider you use. + +### Experimental Providers + +The `NEMOCLAW_EXPERIMENTAL=1` environment variable gates local NVIDIA NIM and local vLLM. + +| Aspect | Detail | +|---|---| +| Default | Disabled. The onboarding wizard does not show these providers. | +| What you can change | Set `NEMOCLAW_EXPERIMENTAL=1` before running `nemoclaw onboard`. | +| Risk if relaxed | NemoClaw has not fully validated these providers. NIM requires a NIM-capable GPU. vLLM must already be running on `localhost:8000`. Misconfiguration can cause failed inference or unexpected behavior. | +| Recommendation | Use experimental providers only for evaluation. Do not rely on them for always-on assistants. | + +## Posture Profiles + +The following profiles describe how to configure NemoClaw for different use cases. +These are not separate policy files. +They provide guidance on which controls to keep tight or relax. + +### Locked-Down (Default) + +Use for always-on assistants with minimal external access. + +- Keep all defaults. Do not add presets. +- Use operator approval for any endpoint the agent requests. +- Use NVIDIA Endpoints or local Ollama for inference. +- Monitor the TUI for unexpected network requests. + +### Development + +Use when the agent needs package registries, Docker Hub, or broader GitHub access during development tasks. + +- Apply the `pypi` and `npm` presets for package installation. +- Apply the `docker` preset if the agent builds or pulls container images. +- Keep binary restrictions on all presets. +- Review the agent's network activity periodically with `openshell term`. +- Use operator approval for any endpoint not covered by a preset. + +### Integration Testing + +Use when the agent talks to internal APIs or third-party services during testing. + +- Add custom endpoint entries with tight path and method restrictions. +- Use `protocol: rest` for all HTTP APIs to maintain inspection. +- Use operator approval for unknown endpoints during test runs. +- Review and clean up the baseline policy after testing. Remove endpoints that are no longer needed. + +## Common Mistakes + +The following patterns weaken security without providing meaningful benefit. + +| Mistake | Why it matters | What to do instead | +|---------|---------------|-------------------| +| Omitting `protocol: rest` on REST API endpoints | Endpoints without a `protocol` field use L4-only enforcement. The proxy allows the TCP stream through after checking host, port, and binary, but cannot see or filter individual HTTP requests. | Add `protocol: rest` with explicit `rules` to enable per-request method and path control on REST APIs. | +| Adding endpoints to the baseline policy for one-off requests | Adding an endpoint to the baseline policy makes it permanently reachable across all sandbox instances. | Use operator approval. Approved endpoints persist within the sandbox instance but reset when you destroy and recreate the sandbox. | +| Relying solely on the entrypoint for capability drops | The entrypoint drops dangerous capabilities using `capsh`, but this is best-effort. If `capsh` is unavailable or `CAP_SETPCAP` is not in the bounding set, the container runs with the default capability set. | Pass `--cap-drop=ALL` at the container runtime level as defense-in-depth. | +| Granting write access to `/sandbox/.openclaw` | This directory contains the OpenClaw gateway configuration. A writable `.openclaw` lets the agent modify auth tokens, disable CORS, or redirect inference routing. | Store agent-writable state in `/sandbox/.openclaw-data`. | +| Adding inference provider hosts to the network policy | Direct network access to an inference host bypasses credential isolation and usage tracking. | Use OpenShell inference routing instead of adding hosts like `api.openai.com` or `api.anthropic.com` to the network policy. | +| Disabling device auth for remote deployments | Without device auth, any device on the network can connect to the gateway without pairing. Combined with a cloudflared tunnel, this makes the dashboard publicly accessible and unauthenticated. | Keep `NEMOCLAW_DISABLE_DEVICE_AUTH` at its default (`0`). Only set it to `1` for local headless or development environments. | + +## Related Topics + +- [Network Policies](../reference/network-policies.md) for the full baseline policy reference. +- [Customize the Network Policy](../network-policy/customize-network-policy.md) for static and dynamic policy changes. +- [Approve or Deny Network Requests](../network-policy/approve-network-requests.md) for the operator approval flow. +- [Sandbox Hardening](../deployment/sandbox-hardening.md) for container-level security measures. +- [Inference Options](../inference/inference-options.md) for provider configuration details. +- [How It Works](../about/how-it-works.md) for the protection layer architecture. + diff --git a/docs/workspace/backup-restore.md b/docs/workspace/backup-restore.md index 79d9695ad..e54365855 100644 --- a/docs/workspace/backup-restore.md +++ b/docs/workspace/backup-restore.md @@ -2,7 +2,9 @@ title: page: "Back Up and Restore Workspace Files" nav: "Back Up & Restore" -description: "How to back up and restore OpenClaw workspace files before destructive operations." +description: + main: "How to back up and restore OpenClaw workspace files before destructive operations." + agent: "Backs up and restores OpenClaw workspace files before destructive operations. Use when backing up a sandbox, restoring workspace state, or preparing for a destructive operation." keywords: ["nemoclaw backup", "nemoclaw restore", "workspace backup", "openshell sandbox download upload"] topics: ["generative_ai", "ai_agents"] tags: ["openclaw", "openshell", "sandboxing", "workspace", "backup", "nemoclaw"] diff --git a/docs/workspace/workspace-files.md b/docs/workspace/workspace-files.md index 1fcbc5bbc..2c0406875 100644 --- a/docs/workspace/workspace-files.md +++ b/docs/workspace/workspace-files.md @@ -2,7 +2,9 @@ title: page: "Workspace Files" nav: "Workspace Files" -description: "What workspace files are, where they live, and how they persist across sandbox restarts." +description: + main: "What workspace files are, where they live, and how they persist across sandbox restarts." + agent: "Explains what workspace files are, where they live, and how they persist across sandbox restarts. Use when asking about soul.md, identity.md, memory.md, agents.md, or sandbox file persistence." keywords: ["nemoclaw workspace files", "soul.md", "user.md", "identity.md", "agents.md", "memory.md", "sandbox persistence"] topics: ["generative_ai", "ai_agents"] tags: ["openclaw", "openshell", "sandboxing", "workspace", "persistence", "nemoclaw"] diff --git a/eslint.config.mjs b/eslint.config.mjs index 4c2856dbc..b6b9b8aa5 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -39,6 +39,8 @@ export default [ rules: { ...js.configs.recommended.rules, "no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_" }], + // Cyclomatic complexity — ratchet down to 15 as we refactor suppressed functions + "complexity": ["error", { max: 20 }], }, }, diff --git a/install.sh b/install.sh index bbb2e7335..fa6ea06a8 100755 --- a/install.sh +++ b/install.sh @@ -2,612 +2,119 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # -# NemoClaw installer — installs Node.js, Ollama (if GPU present), and NemoClaw. +# Thin bootstrap for the NemoClaw installer. +# Public curl|bash installs should select a ref once, clone that ref, then +# execute installer logic from that same clone. Historical tags that predate +# the extracted payload fall back to their own root install.sh. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" -DEFAULT_NEMOCLAW_VERSION="0.1.0" -TOTAL_STEPS=3 +LOCAL_PAYLOAD="${SCRIPT_DIR}/scripts/install.sh" +BOOTSTRAP_TMPDIR="" +PAYLOAD_MARKER="NEMOCLAW_VERSIONED_INSTALLER_PAYLOAD=1" -resolve_installer_version() { - local package_json="${SCRIPT_DIR}/package.json" - local version="" - if [[ -f "$package_json" ]]; then - version="$(sed -nE 's/^[[:space:]]*"version":[[:space:]]*"([^"]+)".*/\1/p' "$package_json" | head -1)" - fi - printf "%s" "${version:-$DEFAULT_NEMOCLAW_VERSION}" +resolve_release_tag() { + printf "%s" "${NEMOCLAW_INSTALL_TAG:-latest}" } -NEMOCLAW_VERSION="$(resolve_installer_version)" - -# --------------------------------------------------------------------------- -# Color / style — disabled when NO_COLOR is set or stdout is not a TTY. -# Uses exact NVIDIA green #76B900 on truecolor terminals; 256-color otherwise. -# --------------------------------------------------------------------------- -if [[ -z "${NO_COLOR:-}" && -t 1 ]]; then - if [[ "${COLORTERM:-}" == "truecolor" || "${COLORTERM:-}" == "24bit" ]]; then - C_GREEN=$'\033[38;2;118;185;0m' # #76B900 — exact NVIDIA green - else - C_GREEN=$'\033[38;5;148m' # closest 256-color on dark backgrounds +verify_downloaded_script() { + local file="$1" label="${2:-installer}" + if [[ ! -s "$file" ]]; then + printf "[ERROR] %s download is empty or missing\n" "$label" >&2 + exit 1 fi - C_BOLD=$'\033[1m' - C_DIM=$'\033[2m' - C_RED=$'\033[1;31m' - C_YELLOW=$'\033[1;33m' - C_CYAN=$'\033[1;36m' - C_RESET=$'\033[0m' -else - C_GREEN='' C_BOLD='' C_DIM='' C_RED='' C_YELLOW='' C_CYAN='' C_RESET='' -fi + if ! head -1 "$file" | grep -qE '^#!.*(sh|bash)'; then + printf "[ERROR] %s does not start with a shell shebang\n" "$label" >&2 + exit 1 + fi +} -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- -info() { printf "${C_CYAN}[INFO]${C_RESET} %s\n" "$*"; } -warn() { printf "${C_YELLOW}[WARN]${C_RESET} %s\n" "$*"; } -error() { - printf "${C_RED}[ERROR]${C_RESET} %s\n" "$*" >&2 - exit 1 +has_payload_marker() { + local file="$1" + [[ -f "$file" ]] && grep -q "$PAYLOAD_MARKER" "$file" } -ok() { printf " ${C_GREEN}✓${C_RESET} %s\n" "$*"; } -resolve_default_sandbox_name() { - local registry_file="${HOME}/.nemoclaw/sandboxes.json" - local sandbox_name="${NEMOCLAW_SANDBOX_NAME:-}" +exec_installer_from_ref() { + local ref="$1" + shift - if [[ -z "$sandbox_name" && -f "$registry_file" ]] && command_exists node; then - sandbox_name="$( - node -e ' - const fs = require("fs"); - const file = process.argv[1]; - try { - const data = JSON.parse(fs.readFileSync(file, "utf8")); - const sandboxes = data.sandboxes || {}; - const preferred = data.defaultSandbox; - const name = (preferred && sandboxes[preferred] && preferred) || Object.keys(sandboxes)[0] || ""; - process.stdout.write(name); - } catch {} - ' "$registry_file" 2>/dev/null || true - )" - fi + local tmpdir source_root payload_script legacy_script + tmpdir="$(mktemp -d)" + BOOTSTRAP_TMPDIR="$tmpdir" + trap 'rm -rf "${BOOTSTRAP_TMPDIR:-}"' EXIT + source_root="${tmpdir}/source" - printf "%s" "${sandbox_name:-my-assistant}" -} + git -c advice.detachedHead=false clone --quiet --depth 1 --branch "$ref" \ + https://github.com/NVIDIA/NemoClaw.git "$source_root" -# step N "Description" — numbered section header -step() { - local n=$1 msg=$2 - printf "\n${C_GREEN}[%s/%s]${C_RESET} ${C_BOLD}%s${C_RESET}\n" \ - "$n" "$TOTAL_STEPS" "$msg" - printf " ${C_DIM}──────────────────────────────────────────────────${C_RESET}\n" -} + payload_script="${source_root}/scripts/install.sh" + legacy_script="${source_root}/install.sh" -print_banner() { - printf "\n" - # ANSI Shadow ASCII art — hand-crafted, no figlet dependency - printf " ${C_GREEN}${C_BOLD} ███╗ ██╗███████╗███╗ ███╗ ██████╗ ██████╗██╗ █████╗ ██╗ ██╗${C_RESET}\n" - printf " ${C_GREEN}${C_BOLD} ████╗ ██║██╔════╝████╗ ████║██╔═══██╗██╔════╝██║ ██╔══██╗██║ ██║${C_RESET}\n" - printf " ${C_GREEN}${C_BOLD} ██╔██╗ ██║█████╗ ██╔████╔██║██║ ██║██║ ██║ ███████║██║ █╗ ██║${C_RESET}\n" - printf " ${C_GREEN}${C_BOLD} ██║╚██╗██║██╔══╝ ██║╚██╔╝██║██║ ██║██║ ██║ ██╔══██║██║███╗██║${C_RESET}\n" - printf " ${C_GREEN}${C_BOLD} ██║ ╚████║███████╗██║ ╚═╝ ██║╚██████╔╝╚██████╗███████╗██║ ██║╚███╔███╔╝${C_RESET}\n" - printf " ${C_GREEN}${C_BOLD} ╚═╝ ╚═══╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝${C_RESET}\n" - printf "\n" - printf " ${C_DIM}Launch OpenClaw in an OpenShell sandbox. v%s${C_RESET}\n" "$NEMOCLAW_VERSION" - printf "\n" + if has_payload_marker "$payload_script"; then + verify_downloaded_script "$payload_script" "versioned installer" + NEMOCLAW_INSTALL_REF="$ref" NEMOCLAW_INSTALL_TAG="$ref" NEMOCLAW_REPO_ROOT="$source_root" \ + bash "$payload_script" "$@" + return + fi + + verify_downloaded_script "$legacy_script" "legacy installer" + NEMOCLAW_INSTALL_TAG="$ref" bash "$legacy_script" "$@" } -print_done() { - local elapsed=$((SECONDS - _INSTALL_START)) - local sandbox_name - sandbox_name="$(resolve_default_sandbox_name)" - info "=== Installation complete ===" - printf "\n" - printf " ${C_GREEN}${C_BOLD}NemoClaw${C_RESET} ${C_DIM}(%ss)${C_RESET}\n" "$elapsed" - printf "\n" - printf " ${C_GREEN}Your OpenClaw Sandbox is live.${C_RESET}\n" - printf " ${C_DIM}Sandbox in, break things, and tell us what you find.${C_RESET}\n" - printf "\n" - printf " ${C_GREEN}Next:${C_RESET}\n" - printf " %s$%s nemoclaw %s connect\n" "$C_GREEN" "$C_RESET" "$sandbox_name" - printf " %ssandbox@%s$%s openclaw tui\n" "$C_GREEN" "$sandbox_name" "$C_RESET" - printf "\n" - printf " ${C_BOLD}GitHub${C_RESET} ${C_DIM}https://github.com/nvidia/nemoclaw${C_RESET}\n" - printf " ${C_BOLD}Docs${C_RESET} ${C_DIM}https://docs.nvidia.com/nemoclaw/latest/${C_RESET}\n" - printf "\n" +bootstrap_version() { + printf "nemoclaw-installer\n" } -usage() { +bootstrap_usage() { printf "\n" - printf " ${C_BOLD}NemoClaw Installer${C_RESET} ${C_DIM}v%s${C_RESET}\n\n" "$NEMOCLAW_VERSION" - printf " ${C_DIM}Usage:${C_RESET}\n" + printf " NemoClaw Installer\n\n" + printf " Usage:\n" printf " curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash\n" printf " curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash -s -- [options]\n\n" - printf " ${C_DIM}Options:${C_RESET}\n" + printf " Options:\n" printf " --non-interactive Skip prompts (uses env vars / defaults)\n" + printf " --yes-i-accept-third-party-software Accept the third-party software notice in non-interactive mode\n" printf " --version, -v Print installer version and exit\n" printf " --help, -h Show this help message and exit\n\n" - printf " ${C_DIM}Environment:${C_RESET}\n" - printf " NVIDIA_API_KEY API key (skips credential prompt)\n" - printf " NEMOCLAW_NON_INTERACTIVE=1 Same as --non-interactive\n" - printf " NEMOCLAW_SANDBOX_NAME Sandbox name to create/use\n" - printf " NEMOCLAW_RECREATE_SANDBOX=1 Recreate an existing sandbox\n" - printf " NEMOCLAW_PROVIDER cloud | ollama | nim | vllm\n" - printf " NEMOCLAW_MODEL Inference model to configure\n" - printf " NEMOCLAW_POLICY_MODE suggested | custom | skip\n" - printf " NEMOCLAW_POLICY_PRESETS Comma-separated policy presets\n" - printf " NEMOCLAW_EXPERIMENTAL=1 Show experimental/local options\n" - printf " CHAT_UI_URL Chat UI URL to open after setup\n" - printf " DISCORD_BOT_TOKEN Auto-enable Discord policy support\n" - printf " SLACK_BOT_TOKEN Auto-enable Slack policy support\n" - printf " TELEGRAM_BOT_TOKEN Auto-enable Telegram policy support\n" + printf " Environment:\n" + printf " NEMOCLAW_INSTALL_TAG Git ref to install (default: latest release)\n" + printf " NEMOCLAW_NON_INTERACTIVE=1 Same as --non-interactive\n" + printf " NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 Same as --yes-i-accept-third-party-software\n" + printf " NEMOCLAW_SANDBOX_NAME Sandbox name to create/use\n" + printf " NEMOCLAW_PROVIDER cloud | ollama | nim | vllm\n" + printf " NEMOCLAW_POLICY_MODE suggested | custom | skip\n" printf "\n" } -# spin "label" cmd [args...] -# Runs a command in the background, showing a braille spinner until it exits. -# Stdout/stderr are captured; dumped only on failure. -# Falls back to plain output when stdout is not a TTY (CI / piped installs). -spin() { - local msg="$1" - shift - - if [[ ! -t 1 ]]; then - info "$msg" - "$@" - return - fi - - local log - log=$(mktemp) - "$@" >"$log" 2>&1 & - local pid=$! i=0 - local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏') - - while kill -0 "$pid" 2>/dev/null; do - printf "\r ${C_GREEN}%s${C_RESET} %s" "${frames[$((i++ % 10))]}" "$msg" - sleep 0.08 - done - - if wait "$pid"; then - local status=0 - else - local status=$? - fi - if [[ $status -eq 0 ]]; then - printf "\r ${C_GREEN}✓${C_RESET} %s\n" "$msg" - else - printf "\r ${C_RED}✗${C_RESET} %s\n\n" "$msg" - cat "$log" >&2 - printf "\n" - fi - rm -f "$log" - return $status -} - -command_exists() { command -v "$1" &>/dev/null; } - -MIN_NODE_MAJOR=20 -MIN_NPM_MAJOR=10 -RECOMMENDED_NODE_MAJOR=22 -RUNTIME_REQUIREMENT_MSG="NemoClaw requires Node.js >=${MIN_NODE_MAJOR} and npm >=${MIN_NPM_MAJOR} (recommended Node.js ${RECOMMENDED_NODE_MAJOR})." -NEMOCLAW_SHIM_DIR="${HOME}/.local/bin" -ORIGINAL_PATH="${PATH:-}" - -# Compare two semver strings (major.minor.patch). Returns 0 if $1 >= $2. -version_gte() { - local -a a b - IFS=. read -ra a <<<"$1" - IFS=. read -ra b <<<"$2" - for i in 0 1 2; do - local ai=${a[$i]:-0} bi=${b[$i]:-0} - if ((ai > bi)); then return 0; fi - if ((ai < bi)); then return 1; fi - done - return 0 -} - -# Ensure nvm environment is loaded in the current shell. -# Skip if node is already on PATH — sourcing nvm.sh can reset PATH and -# override the caller's node/npm (e.g. in test environments with stubs). -ensure_nvm_loaded() { - command -v node &>/dev/null && return 0 - if [[ -z "${NVM_DIR:-}" ]]; then - export NVM_DIR="$HOME/.nvm" - fi - if [[ -s "$NVM_DIR/nvm.sh" ]]; then - \. "$NVM_DIR/nvm.sh" - fi -} - -# Refresh PATH so that npm global bin is discoverable. -# After nvm installs Node.js the global bin lives under the nvm prefix, -# which may not yet be on PATH in the current session. -refresh_path() { - ensure_nvm_loaded - - local npm_bin - npm_bin="$(npm config get prefix 2>/dev/null)/bin" || true - if [[ -n "$npm_bin" && -d "$npm_bin" && ":$PATH:" != *":$npm_bin:"* ]]; then - export PATH="$npm_bin:$PATH" - fi - - if [[ -d "$NEMOCLAW_SHIM_DIR" && ":$PATH:" != *":$NEMOCLAW_SHIM_DIR:"* ]]; then - export PATH="$NEMOCLAW_SHIM_DIR:$PATH" - fi -} - -ensure_nemoclaw_shim() { - local npm_bin shim_path - npm_bin="$(npm config get prefix 2>/dev/null)/bin" || true - shim_path="${NEMOCLAW_SHIM_DIR}/nemoclaw" - - if [[ -z "$npm_bin" || ! -x "$npm_bin/nemoclaw" ]]; then - return 1 - fi - - if [[ ":$ORIGINAL_PATH:" == *":$npm_bin:"* ]] || [[ ":$ORIGINAL_PATH:" == *":$NEMOCLAW_SHIM_DIR:"* ]]; then - return 0 - fi - - mkdir -p "$NEMOCLAW_SHIM_DIR" - ln -sfn "$npm_bin/nemoclaw" "$shim_path" - refresh_path - info "Created user-local shim at $shim_path" - return 0 -} - -version_major() { - printf '%s\n' "${1#v}" | cut -d. -f1 -} - -ensure_supported_runtime() { - command_exists node || error "${RUNTIME_REQUIREMENT_MSG} Node.js was not found on PATH." - command_exists npm || error "${RUNTIME_REQUIREMENT_MSG} npm was not found on PATH." - - local node_version npm_version node_major npm_major - node_version="$(node --version 2>/dev/null || true)" - npm_version="$(npm --version 2>/dev/null || true)" - node_major="$(version_major "$node_version")" - npm_major="$(version_major "$npm_version")" - - [[ "$node_major" =~ ^[0-9]+$ ]] || error "Could not determine Node.js version from '${node_version}'. ${RUNTIME_REQUIREMENT_MSG}" - [[ "$npm_major" =~ ^[0-9]+$ ]] || error "Could not determine npm version from '${npm_version}'. ${RUNTIME_REQUIREMENT_MSG}" - - if ((node_major < MIN_NODE_MAJOR || npm_major < MIN_NPM_MAJOR)); then - error "Unsupported runtime detected: Node.js ${node_version:-unknown}, npm ${npm_version:-unknown}. ${RUNTIME_REQUIREMENT_MSG} Upgrade Node.js and rerun the installer." - fi - - info "Runtime OK: Node.js ${node_version}, npm ${npm_version}" -} - -# --------------------------------------------------------------------------- -# 1. Node.js -# --------------------------------------------------------------------------- -install_nodejs() { - if command_exists node; then - info "Node.js found: $(node --version)" - return - fi - - info "Node.js not found — installing via nvm…" - # IMPORTANT: update NVM_SHA256 when changing NVM_VERSION - local NVM_VERSION="v0.40.4" - local NVM_SHA256="4b7412c49960c7d31e8df72da90c1fb5b8cccb419ac99537b737028d497aba4f" - local nvm_tmp - nvm_tmp="$(mktemp)" - curl -fsSL "https://raw.githubusercontent.com/nvm-sh/nvm/${NVM_VERSION}/install.sh" -o "$nvm_tmp" \ - || { - rm -f "$nvm_tmp" - error "Failed to download nvm installer" - } - local actual_hash - if command_exists sha256sum; then - actual_hash="$(sha256sum "$nvm_tmp" | awk '{print $1}')" - elif command_exists shasum; then - actual_hash="$(shasum -a 256 "$nvm_tmp" | awk '{print $1}')" - else - warn "No SHA-256 tool found — skipping nvm integrity check" - actual_hash="$NVM_SHA256" # allow execution - fi - if [[ "$actual_hash" != "$NVM_SHA256" ]]; then - rm -f "$nvm_tmp" - error "nvm installer integrity check failed\n Expected: $NVM_SHA256\n Actual: $actual_hash" - fi - info "nvm installer integrity verified" - spin "Installing nvm..." bash "$nvm_tmp" - rm -f "$nvm_tmp" - ensure_nvm_loaded - spin "Installing Node.js ${RECOMMENDED_NODE_MAJOR}..." bash -c ". \"$NVM_DIR/nvm.sh\" && nvm install ${RECOMMENDED_NODE_MAJOR} --no-progress" - ensure_nvm_loaded - nvm use "${RECOMMENDED_NODE_MAJOR}" --silent - info "Node.js installed: $(node --version)" -} - -# --------------------------------------------------------------------------- -# 2. Ollama -# --------------------------------------------------------------------------- -OLLAMA_MIN_VERSION="0.18.0" - -get_ollama_version() { - # `ollama --version` outputs something like "ollama version 0.18.0" - ollama --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 -} - -detect_gpu() { - # Returns 0 if a GPU is detected - if command_exists nvidia-smi; then - nvidia-smi &>/dev/null && return 0 - fi - return 1 -} - -get_vram_mb() { - # Returns total VRAM in MiB (NVIDIA only). Falls back to 0. - if command_exists nvidia-smi; then - nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits 2>/dev/null \ - | awk '{s += $1} END {print s+0}' - return - fi - # macOS — report unified memory as VRAM - if [[ "$(uname -s)" == "Darwin" ]] && command_exists sysctl; then - local bytes - bytes=$(sysctl -n hw.memsize 2>/dev/null || echo 0) - echo $((bytes / 1024 / 1024)) - return - fi - echo 0 -} - -install_or_upgrade_ollama() { - if detect_gpu && command_exists ollama; then - local current - current=$(get_ollama_version) - if [[ -n "$current" ]] && version_gte "$current" "$OLLAMA_MIN_VERSION"; then - info "Ollama v${current} meets minimum requirement (>= v${OLLAMA_MIN_VERSION})" - else - info "Ollama v${current:-unknown} is below v${OLLAMA_MIN_VERSION} — upgrading…" - curl -fsSL https://ollama.com/install.sh | sh - info "Ollama upgraded to $(get_ollama_version)" - fi - else - # No ollama — only install if a GPU is present - if detect_gpu; then - info "GPU detected — installing Ollama…" - curl -fsSL https://ollama.com/install.sh | sh - info "Ollama installed: v$(get_ollama_version)" - else - warn "No GPU detected — skipping Ollama installation." - return - fi - fi - - # Pull the appropriate model based on VRAM - local vram_mb - vram_mb=$(get_vram_mb) - local vram_gb=$((vram_mb / 1024)) - info "Detected ${vram_gb} GB VRAM" - - if ((vram_gb >= 120)); then - info "Pulling nemotron-3-super:120b…" - ollama pull nemotron-3-super:120b - else - info "Pulling nemotron-3-nano:30b…" - ollama pull nemotron-3-nano:30b - fi -} - -# --------------------------------------------------------------------------- -# 3. NemoClaw -# --------------------------------------------------------------------------- -# Work around openclaw tarball missing directory entries (GH-503). -# npm's tar extractor hard-fails because the tarball is missing directory -# entries for extensions/, skills/, and dist/plugin-sdk/config/. System tar -# handles this fine. We pre-extract openclaw into node_modules BEFORE npm -# install so npm sees the dependency is already satisfied and skips it. -pre_extract_openclaw() { - local install_dir="$1" - local openclaw_version - openclaw_version=$(node -e "console.log(require('${install_dir}/package.json').dependencies.openclaw)" 2>/dev/null || echo "") - - if [[ -z "$openclaw_version" ]]; then - warn "Could not determine openclaw version — skipping pre-extraction" - return 1 - fi - - info "Pre-extracting openclaw@${openclaw_version} with system tar (GH-503 workaround)…" - local tmpdir - tmpdir="$(mktemp -d)" - if npm pack "openclaw@${openclaw_version}" --pack-destination "$tmpdir" >/dev/null 2>&1; then - local tgz - tgz="$(find "$tmpdir" -maxdepth 1 -name 'openclaw-*.tgz' -print -quit)" - if [[ -n "$tgz" && -f "$tgz" ]]; then - if mkdir -p "${install_dir}/node_modules/openclaw" \ - && tar xzf "$tgz" -C "${install_dir}/node_modules/openclaw" --strip-components=1; then - info "openclaw pre-extracted successfully" - else - warn "Failed to extract openclaw tarball" - rm -rf "$tmpdir" - return 1 - fi - else - warn "npm pack succeeded but tarball not found" - rm -rf "$tmpdir" - return 1 - fi - else - warn "Failed to download openclaw tarball" - rm -rf "$tmpdir" - return 1 - fi - rm -rf "$tmpdir" -} - -install_nemoclaw() { - if [[ -f "./package.json" ]] && grep -q '"name": "nemoclaw"' ./package.json 2>/dev/null; then - info "NemoClaw package.json found in current directory — installing from source…" - spin "Preparing OpenClaw package" bash -c "$(declare -f info warn pre_extract_openclaw); pre_extract_openclaw \"\$1\"" _ "$(pwd)" \ - || warn "Pre-extraction failed — npm install may fail if openclaw tarball is broken" - spin "Installing NemoClaw dependencies" npm install --ignore-scripts - spin "Building NemoClaw plugin" bash -c 'cd nemoclaw && npm install --ignore-scripts && npm run build' - spin "Linking NemoClaw CLI" npm link - else - info "Installing NemoClaw from GitHub…" - # Clone first so we can pre-extract openclaw before npm install (GH-503). - # npm install -g git+https://... does this internally but we can't hook - # into its extraction pipeline, so we do it ourselves. - local nemoclaw_src="${HOME}/.nemoclaw/source" - rm -rf "$nemoclaw_src" - mkdir -p "$(dirname "$nemoclaw_src")" - spin "Cloning NemoClaw source" git clone --depth 1 https://github.com/NVIDIA/NemoClaw.git "$nemoclaw_src" - spin "Preparing OpenClaw package" bash -c "$(declare -f info warn pre_extract_openclaw); pre_extract_openclaw \"\$1\"" _ "$nemoclaw_src" \ - || warn "Pre-extraction failed — npm install may fail if openclaw tarball is broken" - spin "Installing NemoClaw dependencies" bash -c "cd \"$nemoclaw_src\" && npm install --ignore-scripts" - spin "Building NemoClaw plugin" bash -c "cd \"$nemoclaw_src\"/nemoclaw && npm install --ignore-scripts && npm run build" - spin "Linking NemoClaw CLI" bash -c "cd \"$nemoclaw_src\" && npm link" - fi - - refresh_path - ensure_nemoclaw_shim || true -} - -# --------------------------------------------------------------------------- -# 4. Verify -# --------------------------------------------------------------------------- -verify_nemoclaw() { - if command_exists nemoclaw; then - info "Verified: nemoclaw is available at $(command -v nemoclaw)" - return 0 - fi - - # nemoclaw not on PATH — try to diagnose and suggest a fix - warn "nemoclaw is not on PATH after installation." - - local npm_bin - npm_bin="$(npm config get prefix 2>/dev/null)/bin" || true - - if [[ -n "$npm_bin" && -x "$npm_bin/nemoclaw" ]]; then - ensure_nemoclaw_shim || true - if command_exists nemoclaw; then - info "Verified: nemoclaw is available at $(command -v nemoclaw)" - return 0 - fi - - warn "Found nemoclaw at $npm_bin/nemoclaw but could not expose it on PATH." - warn "" - warn "Add one of these directories to your shell profile:" - warn " $NEMOCLAW_SHIM_DIR" - warn " $npm_bin" - warn "" - warn "Continuing — nemoclaw is installed but requires a PATH update." - return 0 - else - warn "Could not locate the nemoclaw executable." - warn "Try running: npm install -g git+https://github.com/NVIDIA/NemoClaw.git" - fi - - error "Installation failed: nemoclaw binary not found." -} - -# --------------------------------------------------------------------------- -# 5. Onboard -# --------------------------------------------------------------------------- -run_onboard() { - info "Running nemoclaw onboard…" - if [ "${NON_INTERACTIVE:-}" = "1" ]; then - nemoclaw onboard --non-interactive - elif [ -t 0 ]; then - nemoclaw onboard - elif exec 3 **⚠️ Experimental**: This deployment method is intended for **trying out NemoClaw on Kubernetes**, not for production use. It requires a **privileged pod** running **Docker-in-Docker (DinD)** to create isolated sandbox environments. Operational requirements (storage, runtime, security policies) vary by cluster configuration. + +Run [NemoClaw](https://github.com/NVIDIA/NemoClaw) on Kubernetes with GPU inference powered by [Dynamo](https://github.com/ai-dynamo/dynamo) or any OpenAI-compatible endpoint. + +--- + +## Quick Start + +### Prerequisites + +- Kubernetes cluster with `kubectl` access +- An OpenAI-compatible inference endpoint (Dynamo vLLM, vLLM, etc.) +- Permissions to create **privileged pods** (required for Docker-in-Docker) +- Sufficient node resources (~8GB memory, 2 CPUs for DinD container) + +### 1. Deploy NemoClaw + +```bash +kubectl create namespace nemoclaw +kubectl apply -f https://raw.githubusercontent.com/NVIDIA/NemoClaw/main/k8s/nemoclaw-k8s.yaml +``` + +### 2. Check Logs + +```bash +kubectl logs -f nemoclaw -n nemoclaw -c workspace +``` + +Wait for "Onboard complete" message. + +### 3. Connect to Your Sandbox + +```bash +kubectl exec -it nemoclaw -n nemoclaw -c workspace -- nemoclaw my-assistant connect +``` + +You're now inside a secure sandbox with an AI agent ready to help. + +--- + +## Configuration + +Edit the environment variables in `nemoclaw-k8s.yaml` before deploying: + +| Variable | Required | Description | +|----------|----------|-------------| +| `DYNAMO_HOST` | Yes | Inference endpoint for socat proxy (e.g., `vllm-frontend.dynamo.svc:8000`) | +| `NEMOCLAW_ENDPOINT_URL` | Yes | URL the sandbox uses (usually `http://host.openshell.internal:8000/v1`) | +| `COMPATIBLE_API_KEY` | Yes | API key (use `dummy` for Dynamo/vLLM) | +| `NEMOCLAW_MODEL` | Yes | Model name (e.g., `meta-llama/Llama-3.1-8B-Instruct`) | +| `NEMOCLAW_SANDBOX_NAME` | No | Sandbox name (default: `my-assistant`) | + +### Example: Custom Endpoint + +```yaml +env: + - name: DYNAMO_HOST + value: "my-vllm.my-namespace.svc.cluster.local:8000" + - name: NEMOCLAW_ENDPOINT_URL + value: "http://host.openshell.internal:8000/v1" + - name: COMPATIBLE_API_KEY + value: "dummy" + - name: NEMOCLAW_MODEL + value: "mistralai/Mistral-7B-Instruct-v0.3" +``` + +--- + +## Using NemoClaw + +### Access the Workspace Shell + +```bash +kubectl exec -it nemoclaw -n nemoclaw -c workspace -- bash +``` + +### Check Sandbox Status + +```bash +kubectl exec nemoclaw -n nemoclaw -c workspace -- nemoclaw list +kubectl exec nemoclaw -n nemoclaw -c workspace -- nemoclaw my-assistant status +``` + +### Connect to Sandbox + +```bash +kubectl exec -it nemoclaw -n nemoclaw -c workspace -- nemoclaw my-assistant connect +``` + +### Test Inference + +From inside the sandbox: + +```bash +curl -s https://inference.local/v1/models + +curl -s https://inference.local/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{"model":"meta-llama/Llama-3.1-8B-Instruct","messages":[{"role":"user","content":"Hello!"}],"max_tokens":50}' +``` + +### Verify Local Inference + +Confirm NemoClaw is using your Dynamo/vLLM endpoint: + +```bash +# Check model from sandbox +kubectl exec -it nemoclaw -n nemoclaw -c workspace -- nemoclaw my-assistant connect +sandbox@my-assistant:~$ curl -s https://inference.local/v1/models +# Should show your model (e.g., meta-llama/Llama-3.1-8B-Instruct) + +# Compare with Dynamo directly (from workspace) +kubectl exec nemoclaw -n nemoclaw -c workspace -- curl -s http://localhost:8000/v1/models +# Should show the same model + +# Check provider configuration +kubectl exec nemoclaw -n nemoclaw -c workspace -- openshell inference get +# Shows: Provider: compatible-endpoint, Model: + +# Test the agent +sandbox@my-assistant:~$ openclaw agent --agent main -m "What is 7 times 8?" +# Should respond with 56 +``` + +--- + +## Architecture + +```text +┌─────────────────────────────────────────────────────────────────┐ +│ Kubernetes Cluster │ +│ │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ NemoClaw Pod │ │ +│ │ │ │ +│ │ ┌─────────────────┐ ┌─────────────────────────────┐ │ │ +│ │ │ Docker-in-Docker│ │ Workspace Container │ │ │ +│ │ │ │ │ │ │ │ +│ │ │ ┌───────────┐ │ │ nemoclaw CLI │ │ │ +│ │ │ │ k3s │ │◄───│ openshell CLI │ │ │ +│ │ │ │ cluster │ │ │ │ │ │ +│ │ │ │ │ │ │ socat proxy ───────────────│───│──┼──► Dynamo/vLLM +│ │ │ │ ┌───────┐ │ │ │ localhost:8000 │ │ │ +│ │ │ │ │Sandbox│ │ │ │ │ │ │ +│ │ │ │ └───────┘ │ │ │ host.openshell.internal │ │ │ +│ │ │ └───────────┘ │ │ routes to socat │ │ │ +│ │ └─────────────────┘ └─────────────────────────────┘ │ │ +│ └───────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**How it works:** + +1. NemoClaw runs in a privileged pod with Docker-in-Docker +2. OpenShell creates a nested k3s cluster for sandbox isolation +3. A socat proxy bridges K8s DNS to the nested environment +4. Inside the sandbox, `host.openshell.internal:8000` routes to the inference endpoint + +--- + +## Troubleshooting + +### Pod won't start + +```bash +kubectl describe pod nemoclaw -n nemoclaw +``` + +Common issues: + +- Missing privileged security context +- Insufficient memory (needs ~8GB for DinD) + +### Docker daemon not starting + +```bash +kubectl logs nemoclaw -n nemoclaw -c dind +``` + +Usually resolves after 30-60 seconds. + +### Inference not working + +Check socat is running: + +```bash +kubectl exec nemoclaw -n nemoclaw -c workspace -- pgrep -a socat +``` + +Test endpoint directly: + +```bash +kubectl exec nemoclaw -n nemoclaw -c workspace -- curl -s http://localhost:8000/v1/models +``` + +--- + +## Learn More + +- [NemoClaw Documentation](https://docs.nvidia.com/nemoclaw) +- [OpenShell](https://github.com/NVIDIA/OpenShell) +- [Dynamo](https://github.com/ai-dynamo/dynamo) +- [OpenClaw](https://openclaw.ai) diff --git a/k8s/nemoclaw-k8s.yaml b/k8s/nemoclaw-k8s.yaml new file mode 100644 index 000000000..edc8748cf --- /dev/null +++ b/k8s/nemoclaw-k8s.yaml @@ -0,0 +1,119 @@ +# NemoClaw on Kubernetes +# Uses official installer with Docker-in-Docker for sandbox isolation. +# Prerequisites: kubectl create namespace nemoclaw +apiVersion: v1 +kind: Pod +metadata: + name: nemoclaw + namespace: nemoclaw + labels: + app: nemoclaw +spec: + containers: + # Docker daemon (DinD) + - name: dind + image: docker:24-dind + securityContext: + privileged: true + env: + - name: DOCKER_TLS_CERTDIR + value: "" + command: ["dockerd", "--host=unix:///var/run/docker.sock"] + volumeMounts: + - name: docker-storage + mountPath: /var/lib/docker + - name: docker-socket + mountPath: /var/run + - name: docker-config + mountPath: /etc/docker + resources: + requests: + memory: "8Gi" + cpu: "2" + + # Workspace - runs official NemoClaw installer + - name: workspace + image: node:22 + command: + - bash + - -c + - | + set -e + + # Install packages + echo "[1/4] Installing packages..." + apt-get update -qq + apt-get install -y -qq docker.io socat curl >/dev/null 2>&1 + + # Start socat proxy for K8s DNS bridge + echo "[2/4] Starting socat proxy..." + socat TCP-LISTEN:8000,fork,reuseaddr TCP:$DYNAMO_HOST & + # Add hosts entry so validation can reach socat via host.openshell.internal + echo "127.0.0.1 host.openshell.internal" >> /etc/hosts + sleep 1 + + # Wait for Docker + echo "[3/4] Waiting for Docker daemon..." + for i in $(seq 1 30); do + if docker info >/dev/null 2>&1; then break; fi + sleep 2 + done + docker info >/dev/null 2>&1 || { echo "Docker not ready"; exit 1; } + echo "Docker ready" + + # Run official NemoClaw installer + echo "[4/4] Running NemoClaw installer..." + curl -fsSL https://nvidia.com/nemoclaw.sh | bash + + # Keep running after onboard + echo "Onboard complete. Container staying alive." + exec sleep infinity + env: + - name: DOCKER_HOST + value: unix:///var/run/docker.sock + # Dynamo endpoint (raw host:port for socat) - UPDATE THIS FOR YOUR CLUSTER + - name: DYNAMO_HOST + value: "vllm-agg-frontend.dynamo.svc.cluster.local:8000" + # NemoClaw config (uses host.openshell.internal via socat) + - name: NEMOCLAW_NON_INTERACTIVE + value: "1" + - name: NEMOCLAW_PROVIDER + value: "custom" + - name: NEMOCLAW_ENDPOINT_URL + value: "http://host.openshell.internal:8000/v1" + - name: COMPATIBLE_API_KEY + value: "dummy" + - name: NEMOCLAW_MODEL + value: "meta-llama/Llama-3.1-8B-Instruct" + - name: NEMOCLAW_SANDBOX_NAME + value: "my-assistant" + - name: NEMOCLAW_POLICY_MODE + value: "skip" + volumeMounts: + - name: docker-socket + mountPath: /var/run + - name: docker-config + mountPath: /etc/docker + resources: + requests: + memory: "4Gi" + cpu: "2" + + initContainers: + # Configure Docker daemon for cgroup v2 + - name: init-docker-config + image: busybox + command: ["sh", "-c", "echo '{\"default-cgroupns-mode\":\"host\"}' > /etc/docker/daemon.json"] + volumeMounts: + - name: docker-config + mountPath: /etc/docker + + volumes: + - name: docker-storage + emptyDir: {} + - name: docker-socket + emptyDir: {} + - name: docker-config + emptyDir: {} + + restartPolicy: Never diff --git a/nemoclaw-blueprint/policies/openclaw-sandbox.yaml b/nemoclaw-blueprint/policies/openclaw-sandbox.yaml index 4b877aa13..b29eaa2dd 100644 --- a/nemoclaw-blueprint/policies/openclaw-sandbox.yaml +++ b/nemoclaw-blueprint/policies/openclaw-sandbox.yaml @@ -53,15 +53,24 @@ network_policies: enforcement: enforce tls: terminate rules: - - allow: { method: "*", path: "/**" } + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } - host: statsig.anthropic.com port: 443 + protocol: rest + enforcement: enforce + tls: terminate rules: - - allow: { method: "*", path: "/**" } + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } - host: sentry.io port: 443 + protocol: rest + enforcement: enforce + tls: terminate rules: - - allow: { method: "*", path: "/**" } + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } binaries: - { path: /usr/local/bin/claude } @@ -74,14 +83,16 @@ network_policies: enforcement: enforce tls: terminate rules: - - allow: { method: "*", path: "/**" } + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } - host: inference-api.nvidia.com port: 443 protocol: rest enforcement: enforce tls: terminate rules: - - allow: { method: "*", path: "/**" } + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } binaries: - { path: /usr/local/bin/claude } - { path: /usr/local/bin/openclaw } @@ -101,14 +112,14 @@ network_policies: # ── OpenClaw "phone home" ──────────────────────────────────────────── # Minimum viable set for OpenClaw to authenticate, discover plugins, - # and reach ClawHub. Binary-restricted to openclaw only. + # and reach ClawHub. Restricted to openclaw and node (skill flows run on Node). # Docs access is read-only (GET). ClawHub and openclaw.ai are # restricted to GET+POST (auth flows, plugin discovery). clawhub: name: clawhub endpoints: - - host: clawhub.com + - host: clawhub.ai port: 443 protocol: rest enforcement: enforce @@ -118,6 +129,7 @@ network_policies: - allow: { method: POST, path: "/**" } binaries: - { path: /usr/local/bin/openclaw } + - { path: /usr/local/bin/node } openclaw_api: name: openclaw_api @@ -132,6 +144,7 @@ network_policies: - allow: { method: POST, path: "/**" } binaries: - { path: /usr/local/bin/openclaw } + - { path: /usr/local/bin/node } openclaw_docs: name: openclaw_docs @@ -156,6 +169,7 @@ network_policies: binaries: - { path: /usr/local/bin/openclaw } - { path: /usr/local/bin/npm } + - { path: /usr/local/bin/node } # ── Messaging — pre-allowed for OpenClaw agent notifications ──── # Restricted to node processes to prevent arbitrary data exfiltration @@ -171,6 +185,7 @@ network_policies: rules: - allow: { method: GET, path: "/bot*/**" } - allow: { method: POST, path: "/bot*/**" } + - allow: { method: GET, path: "/file/bot*/**" } binaries: - { path: /usr/local/bin/node } @@ -185,14 +200,13 @@ network_policies: rules: - allow: { method: GET, path: "/**" } - allow: { method: POST, path: "/**" } + # WebSocket gateway — must use access: full (CONNECT tunnel) instead + # of protocol: rest. The proxy's HTTP idle timeout (~2 min) kills + # long-lived WebSocket connections; a CONNECT tunnel avoids + # HTTP-level timeouts entirely. Matches presets/discord.yaml. See #409. - host: gateway.discord.gg port: 443 - protocol: rest - enforcement: enforce - tls: terminate - rules: - - allow: { method: GET, path: "/**" } - - allow: { method: POST, path: "/**" } + access: full - host: cdn.discordapp.com port: 443 protocol: rest diff --git a/nemoclaw-blueprint/policies/presets/discord.yaml b/nemoclaw-blueprint/policies/presets/discord.yaml index e1b09aaf8..8ffd1bc63 100644 --- a/nemoclaw-blueprint/policies/presets/discord.yaml +++ b/nemoclaw-blueprint/policies/presets/discord.yaml @@ -17,15 +17,25 @@ network_policies: rules: - allow: { method: GET, path: "/**" } - allow: { method: POST, path: "/**" } + - allow: { method: PUT, path: "/**" } + - allow: { method: PATCH, path: "/**" } + - allow: { method: DELETE, path: "/**" } + # WebSocket gateway — must use access: full (CONNECT tunnel) instead + # of protocol: rest. The proxy's HTTP idle timeout (~2 min) kills + # long-lived WebSocket connections; a CONNECT tunnel avoids + # HTTP-level timeouts entirely. See #409. - host: gateway.discord.gg + port: 443 + access: full + - host: cdn.discordapp.com port: 443 protocol: rest enforcement: enforce tls: terminate rules: - allow: { method: GET, path: "/**" } - - allow: { method: POST, path: "/**" } - - host: cdn.discordapp.com + # Media/attachment access (read-only, proxied through Discord CDN) + - host: media.discordapp.net port: 443 protocol: rest enforcement: enforce diff --git a/nemoclaw-blueprint/policies/presets/slack.yaml b/nemoclaw-blueprint/policies/presets/slack.yaml index 866ad34a1..e2a7c4706 100644 --- a/nemoclaw-blueprint/policies/presets/slack.yaml +++ b/nemoclaw-blueprint/policies/presets/slack.yaml @@ -3,7 +3,7 @@ preset: name: slack - description: "Slack API and webhooks access" + description: "Slack API, Socket Mode, and webhooks access" network_policies: slack: @@ -33,5 +33,13 @@ network_policies: rules: - allow: { method: GET, path: "/**" } - allow: { method: POST, path: "/**" } + # Socket Mode WebSocket — requires CONNECT tunnel to avoid + # HTTP idle timeout killing the persistent connection. See #409. + - host: wss-primary.slack.com + port: 443 + access: full + - host: wss-backup.slack.com + port: 443 + access: full binaries: - { path: /usr/local/bin/node } diff --git a/nemoclaw-blueprint/policies/presets/telegram.yaml b/nemoclaw-blueprint/policies/presets/telegram.yaml index b80d7b959..0e733043b 100644 --- a/nemoclaw-blueprint/policies/presets/telegram.yaml +++ b/nemoclaw-blueprint/policies/presets/telegram.yaml @@ -17,5 +17,6 @@ network_policies: rules: - allow: { method: GET, path: "/bot*/**" } - allow: { method: POST, path: "/bot*/**" } + - allow: { method: GET, path: "/file/bot*/**" } binaries: - { path: /usr/local/bin/node } diff --git a/nemoclaw/package-lock.json b/nemoclaw/package-lock.json index 7a44e78cb..2a8023b9c 100644 --- a/nemoclaw/package-lock.json +++ b/nemoclaw/package-lock.json @@ -16,7 +16,7 @@ "yaml": "^2.4.0" }, "devDependencies": { - "@types/node": "^20.19.37", + "@types/node": "^22.0.0", "@typescript-eslint/eslint-plugin": "^8.57.0", "@typescript-eslint/parser": "^8.57.0", "eslint": "^9.39.4", @@ -26,7 +26,7 @@ "vitest": "^4.1.0" }, "engines": { - "node": ">=22.0.0" + "node": ">=22.16.0" } }, "node_modules/@emnapi/core": { @@ -691,9 +691,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.19.37", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", - "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/nemoclaw/package.json b/nemoclaw/package.json index 0493fe152..d338ee785 100644 --- a/nemoclaw/package.json +++ b/nemoclaw/package.json @@ -29,7 +29,7 @@ "yaml": "^2.4.0" }, "devDependencies": { - "@types/node": "^20.19.37", + "@types/node": "^22.0.0", "@typescript-eslint/eslint-plugin": "^8.57.0", "@typescript-eslint/parser": "^8.57.0", "eslint": "^9.39.4", @@ -39,7 +39,7 @@ "vitest": "^4.1.0" }, "engines": { - "node": ">=22.0.0" + "node": ">=22.16.0" }, "files": [ "dist/", diff --git a/nemoclaw/src/blueprint/runner.test.ts b/nemoclaw/src/blueprint/runner.test.ts index 46249c181..f13a78dc5 100644 --- a/nemoclaw/src/blueprint/runner.test.ts +++ b/nemoclaw/src/blueprint/runner.test.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import type fs from "node:fs"; import YAML from "yaml"; // ── In-memory filesystem ──────────────────────────────────────── @@ -32,7 +33,7 @@ vi.mock("node:crypto", () => ({ })); vi.mock("node:fs", async (importOriginal) => { - const original = await importOriginal(); + const original = await importOriginal(); return { ...original, existsSync: (p: string) => store.has(p), diff --git a/nemoclaw/src/blueprint/snapshot.test.ts b/nemoclaw/src/blueprint/snapshot.test.ts index 0b0b0afde..803fff1f6 100644 --- a/nemoclaw/src/blueprint/snapshot.test.ts +++ b/nemoclaw/src/blueprint/snapshot.test.ts @@ -2,6 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import type fs from "node:fs"; +const SNAP = "/snap/20260323"; // ── In-memory filesystem ──────────────────────────────────────── @@ -27,7 +29,7 @@ vi.mock("node:os", () => ({ })); vi.mock("node:fs", async (importOriginal) => { - const original = await importOriginal(); + const original = await importOriginal(); return { ...original, existsSync: (p: string) => store.has(p), @@ -123,7 +125,9 @@ describe("snapshot", () => { const result = createSnapshot(); + expect(result).not.toBeNull(); if (!result) throw new Error("createSnapshot returned null"); + expect(result.startsWith(SNAPSHOTS_DIR)).toBe(true); // Manifest was written @@ -140,34 +144,34 @@ describe("snapshot", () => { describe("restoreIntoSandbox", () => { it("returns false when snapshot has no openclaw dir", async () => { - addDir("/snap/20260323"); - expect(await restoreIntoSandbox("/snap/20260323")).toBe(false); + addDir(SNAP); + expect(await restoreIntoSandbox(SNAP)).toBe(false); }); it("calls openshell sandbox cp and returns true on success", async () => { - addDir("/snap/20260323/openclaw"); + addDir(`${SNAP}/openclaw`); mockExeca.mockResolvedValue({ exitCode: 0 }); - expect(await restoreIntoSandbox("/snap/20260323", "mybox")).toBe(true); + expect(await restoreIntoSandbox(SNAP, "mybox")).toBe(true); expect(mockExeca).toHaveBeenCalledWith( "openshell", - ["sandbox", "cp", "/snap/20260323/openclaw", "mybox:/sandbox/.openclaw"], + ["sandbox", "cp", `${SNAP}/openclaw`, "mybox:/sandbox/.openclaw"], { reject: false }, ); }); it("returns false when openshell fails", async () => { - addDir("/snap/20260323/openclaw"); + addDir(`${SNAP}/openclaw`); mockExeca.mockResolvedValue({ exitCode: 1 }); - expect(await restoreIntoSandbox("/snap/20260323")).toBe(false); + expect(await restoreIntoSandbox(SNAP)).toBe(false); }); it("uses default sandbox name 'openclaw'", async () => { - addDir("/snap/20260323/openclaw"); + addDir(`${SNAP}/openclaw`); mockExeca.mockResolvedValue({ exitCode: 0 }); - await restoreIntoSandbox("/snap/20260323"); + await restoreIntoSandbox(SNAP); expect(mockExeca).toHaveBeenCalledWith( "openshell", expect.arrayContaining(["openclaw:/sandbox/.openclaw"]), @@ -207,15 +211,15 @@ describe("snapshot", () => { describe("rollbackFromSnapshot", () => { it("returns false when snapshot openclaw dir is missing", () => { - addDir("/snap/20260323"); - expect(rollbackFromSnapshot("/snap/20260323")).toBe(false); + addDir(SNAP); + expect(rollbackFromSnapshot(SNAP)).toBe(false); }); it("restores snapshot to ~/.openclaw with content", () => { - addDir("/snap/20260323/openclaw"); - addFile("/snap/20260323/openclaw/openclaw.json", '{"restored":true}'); + addDir(`${SNAP}/openclaw`); + addFile(`${SNAP}/openclaw/openclaw.json`, '{"restored":true}'); - expect(rollbackFromSnapshot("/snap/20260323")).toBe(true); + expect(rollbackFromSnapshot(SNAP)).toBe(true); const restored = store.get(`${OPENCLAW_DIR}/openclaw.json`); if (!restored) throw new Error("openclaw.json not restored"); @@ -225,10 +229,10 @@ describe("snapshot", () => { it("archives existing ~/.openclaw before restoring", () => { addDir(OPENCLAW_DIR); addFile(`${OPENCLAW_DIR}/openclaw.json`, '{"old":true}'); - addDir("/snap/20260323/openclaw"); - addFile("/snap/20260323/openclaw/openclaw.json", '{"restored":true}'); + addDir(`${SNAP}/openclaw`); + addFile(`${SNAP}/openclaw/openclaw.json`, '{"restored":true}'); - expect(rollbackFromSnapshot("/snap/20260323")).toBe(true); + expect(rollbackFromSnapshot(SNAP)).toBe(true); const archived = [...store.keys()].find((k) => k.includes(".openclaw.nemoclaw-archived.")); expect(archived).toBeDefined(); diff --git a/nemoclaw/src/blueprint/ssrf.test.ts b/nemoclaw/src/blueprint/ssrf.test.ts index bf66304b7..3402d143b 100644 --- a/nemoclaw/src/blueprint/ssrf.test.ts +++ b/nemoclaw/src/blueprint/ssrf.test.ts @@ -35,6 +35,9 @@ describe("isPrivateIp", () => { "::ffff:10.0.0.1", // IPv4-mapped IPv6 — private 10/8 "::ffff:192.168.1.1", // IPv4-mapped IPv6 — private 192.168/16 "::ffff:172.16.0.1", // IPv4-mapped IPv6 — private 172.16/12 + "100.64.0.1", // RFC 6598 CGNAT + "100.127.255.254", // RFC 6598 CGNAT upper bound + "::ffff:100.64.0.1", // IPv4-mapped IPv6 — CGNAT ])("detects private IP: %s", (ip) => { expect(isPrivateIp(ip)).toBe(true); }); diff --git a/nemoclaw/src/blueprint/ssrf.ts b/nemoclaw/src/blueprint/ssrf.ts index 88cc705c3..0a5c41cee 100644 --- a/nemoclaw/src/blueprint/ssrf.ts +++ b/nemoclaw/src/blueprint/ssrf.ts @@ -15,6 +15,7 @@ const PRIVATE_NETWORKS: CidrRange[] = [ cidr("172.16.0.0", 12), cidr("192.168.0.0", 16), cidr("169.254.0.0", 16), + cidr("100.64.0.0", 10), // RFC 6598 CGNAT (shared address space) cidr6("::1", 128), cidr6("fd00::", 8), ]; diff --git a/nemoclaw/src/blueprint/state.test.ts b/nemoclaw/src/blueprint/state.test.ts index 665f96ddf..d2efc8ff1 100644 --- a/nemoclaw/src/blueprint/state.test.ts +++ b/nemoclaw/src/blueprint/state.test.ts @@ -2,12 +2,13 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, beforeEach, vi } from "vitest"; +import type fs from "node:fs"; import { loadState, saveState, clearState, type NemoClawState } from "./state.js"; const store = new Map(); vi.mock("node:fs", async (importOriginal) => { - const original = await importOriginal(); + const original = await importOriginal(); return { ...original, existsSync: (p: string) => store.has(p), diff --git a/nemoclaw/src/commands/migration-state.test.ts b/nemoclaw/src/commands/migration-state.test.ts index 55252dcb6..70575be66 100644 --- a/nemoclaw/src/commands/migration-state.test.ts +++ b/nemoclaw/src/commands/migration-state.test.ts @@ -48,10 +48,11 @@ vi.mock("node:fs", async (importOriginal) => { if (!entry) throw new Error(`ENOENT: ${src}`); store.set(dest, { ...entry }); }), - cpSync: vi.fn((src: string, dest: string) => { + cpSync: vi.fn((src: string, dest: string, opts?: { filter?: (source: string) => boolean }) => { // Shallow copy: copy all entries whose path starts with src for (const [k, v] of store) { if (k === src || k.startsWith(src + "/")) { + if (opts?.filter && !opts.filter(k)) continue; const relative = k.slice(src.length); store.set(dest + relative, { ...v }); } @@ -455,7 +456,7 @@ describe("commands/migration-state", () => { expect.unreachable("bundle should not be null"); return; } - expect(bundle.manifest.version).toBe(2); + expect(bundle.manifest.version).toBe(3); expect(bundle.manifest.homeDir).toBe("/home/user"); expect(bundle.temporary).toBe(false); }); @@ -529,6 +530,294 @@ describe("commands/migration-state", () => { } expect(bundle.manifest.externalRoots.length).toBe(1); }); + + it("excludes auth-profiles.json from snapshot", () => { + const logger = makeLogger(); + addDir("/home/user/.openclaw"); + addFile("/home/user/.openclaw/openclaw.json", JSON.stringify({ version: 1 })); + addDir("/home/user/.openclaw/agents/main/agent"); + addFile( + "/home/user/.openclaw/agents/main/agent/auth-profiles.json", + JSON.stringify({ "nvidia:manual": { type: "api_key" } }), + ); + addFile( + "/home/user/.openclaw/agents/main/agent/config.json", + JSON.stringify({ name: "main" }), + ); + + const hostState: HostOpenClawState = { + exists: true, + homeDir: "/home/user", + stateDir: "/home/user/.openclaw", + configDir: "/home/user/.openclaw", + configPath: "/home/user/.openclaw/openclaw.json", + workspaceDir: null, + extensionsDir: null, + skillsDir: null, + hooksDir: null, + externalRoots: [], + warnings: [], + errors: [], + hasExternalConfig: false, + }; + + const bundle = createSnapshotBundle(hostState, logger, { persist: false }); + if (bundle === null) { + expect.unreachable("bundle should not be null"); + return; + } + + // auth-profiles.json should not exist anywhere in the snapshot + const snapshotKeys = [...store.keys()].filter((k) => k.startsWith(bundle.snapshotDir)); + const authProfileKeys = snapshotKeys.filter((k) => k.endsWith("auth-profiles.json")); + expect(authProfileKeys).toHaveLength(0); + + // config.json should still be present + const configKeys = snapshotKeys.filter((k) => k.endsWith("agents/main/agent/config.json")); + expect(configKeys.length).toBeGreaterThan(0); + }); + + it("strips gateway key and credential fields from sandbox openclaw.json", () => { + const logger = makeLogger(); + addDir("/home/user/.openclaw"); + addFile( + "/home/user/.openclaw/openclaw.json", + JSON.stringify({ + version: 1, + gateway: { auth: { token: "secret123" } }, + nvidia: { apiKey: "nvapi-test-key" }, + agents: { defaults: { model: { primary: "test-model" } } }, + }), + ); + + const hostState: HostOpenClawState = { + exists: true, + homeDir: "/home/user", + stateDir: "/home/user/.openclaw", + configDir: "/home/user/.openclaw", + configPath: "/home/user/.openclaw/openclaw.json", + workspaceDir: null, + extensionsDir: null, + skillsDir: null, + hooksDir: null, + externalRoots: [], + warnings: [], + errors: [], + hasExternalConfig: false, + }; + + const bundle = createSnapshotBundle(hostState, logger, { persist: false }); + if (bundle === null) { + expect.unreachable("bundle should not be null"); + return; + } + + // Read the sandbox-bundle openclaw.json + const sandboxConfigEntry = store.get(bundle.preparedStateDir + "/openclaw.json"); + if (!sandboxConfigEntry?.content) { + expect.unreachable("sandbox config entry should exist with content"); + return; + } + const sandboxConfig = JSON.parse(sandboxConfigEntry.content); + // gateway key should be removed entirely + expect(sandboxConfig).not.toHaveProperty("gateway"); + // credential fields should be stripped + expect(sandboxConfig.nvidia.apiKey).toBe("[STRIPPED_BY_MIGRATION]"); + // non-credential fields should be preserved + expect(sandboxConfig.version).toBe(1); + expect(sandboxConfig.agents.defaults.model.primary).toBe("test-model"); + }); + + it("strips pattern-matched credential fields (accessToken, privateKey, etc.)", () => { + const logger = makeLogger(); + addDir("/home/user/.openclaw"); + addFile( + "/home/user/.openclaw/openclaw.json", + JSON.stringify({ + version: 1, + provider: { + accessToken: "test-access-token", + refreshToken: "test-refresh-token", + privateKey: "test-private-key", + clientSecret: "test-client-secret", + displayName: "should-be-preserved", + }, + }), + ); + + const hostState: HostOpenClawState = { + exists: true, + homeDir: "/home/user", + stateDir: "/home/user/.openclaw", + configDir: "/home/user/.openclaw", + configPath: "/home/user/.openclaw/openclaw.json", + workspaceDir: null, + extensionsDir: null, + skillsDir: null, + hooksDir: null, + externalRoots: [], + warnings: [], + errors: [], + hasExternalConfig: false, + }; + + const bundle = createSnapshotBundle(hostState, logger, { persist: false }); + if (bundle === null) { + expect.unreachable("bundle should not be null"); + return; + } + + const sandboxConfigEntry = store.get(bundle.preparedStateDir + "/openclaw.json"); + if (!sandboxConfigEntry?.content) { + expect.unreachable("sandbox config entry should exist with content"); + return; + } + const sandboxConfig = JSON.parse(sandboxConfigEntry.content); + expect(sandboxConfig.provider.accessToken).toBe("[STRIPPED_BY_MIGRATION]"); + expect(sandboxConfig.provider.refreshToken).toBe("[STRIPPED_BY_MIGRATION]"); + expect(sandboxConfig.provider.privateKey).toBe("[STRIPPED_BY_MIGRATION]"); + expect(sandboxConfig.provider.clientSecret).toBe("[STRIPPED_BY_MIGRATION]"); + expect(sandboxConfig.provider.displayName).toBe("should-be-preserved"); + }); + + it("records blueprintDigest when blueprintPath is provided", () => { + const logger = makeLogger(); + addDir("/home/user/.openclaw"); + addFile("/home/user/.openclaw/openclaw.json", JSON.stringify({ version: 1 })); + addFile("/test/blueprint.yaml", "version: 0.1.0\ndigest: ''\n"); + + const hostState: HostOpenClawState = { + exists: true, + homeDir: "/home/user", + stateDir: "/home/user/.openclaw", + configDir: "/home/user/.openclaw", + configPath: "/home/user/.openclaw/openclaw.json", + workspaceDir: null, + extensionsDir: null, + skillsDir: null, + hooksDir: null, + externalRoots: [], + warnings: [], + errors: [], + hasExternalConfig: false, + }; + + const bundle = createSnapshotBundle(hostState, logger, { + persist: false, + blueprintPath: "/test/blueprint.yaml", + }); + if (bundle === null) { + expect.unreachable("bundle should not be null"); + return; + } + expect(typeof bundle.manifest.blueprintDigest).toBe("string"); + expect((bundle.manifest.blueprintDigest ?? "").length).toBeGreaterThan(0); + }); + + it("blueprintDigest is undefined when no blueprintPath given", () => { + const logger = makeLogger(); + addDir("/home/user/.openclaw"); + addFile("/home/user/.openclaw/openclaw.json", JSON.stringify({ version: 1 })); + + const hostState: HostOpenClawState = { + exists: true, + homeDir: "/home/user", + stateDir: "/home/user/.openclaw", + configDir: "/home/user/.openclaw", + configPath: "/home/user/.openclaw/openclaw.json", + workspaceDir: null, + extensionsDir: null, + skillsDir: null, + hooksDir: null, + externalRoots: [], + warnings: [], + errors: [], + hasExternalConfig: false, + }; + + const bundle = createSnapshotBundle(hostState, logger, { persist: false }); + if (bundle === null) { + expect.unreachable("bundle should not be null"); + return; + } + expect(bundle.manifest.blueprintDigest).toBeUndefined(); + }); + + it("fails when blueprintPath is provided but file is missing", () => { + const logger = makeLogger(); + addDir("/home/user/.openclaw"); + addFile("/home/user/.openclaw/openclaw.json", JSON.stringify({ version: 1 })); + + const hostState: HostOpenClawState = { + exists: true, + homeDir: "/home/user", + stateDir: "/home/user/.openclaw", + configDir: "/home/user/.openclaw", + configPath: "/home/user/.openclaw/openclaw.json", + workspaceDir: null, + extensionsDir: null, + skillsDir: null, + hooksDir: null, + externalRoots: [], + warnings: [], + errors: [], + hasExternalConfig: false, + }; + + // /test/nonexistent.yaml does not exist in store + const bundle = createSnapshotBundle(hostState, logger, { + persist: false, + blueprintPath: "/test/nonexistent.yaml", + }); + expect(bundle).toBeNull(); + expect(logger.error).toHaveBeenCalled(); + }); + + it("sanitizes credentials in the snapshot directory itself (not just sandbox-bundle)", () => { + const logger = makeLogger(); + addDir("/home/user/.openclaw"); + addFile( + "/home/user/.openclaw/openclaw.json", + JSON.stringify({ + version: 1, + gateway: { auth: { token: "secret123" } }, + nvidia: { apiKey: "nvapi-test-key" }, + }), + ); + + const hostState: HostOpenClawState = { + exists: true, + homeDir: "/home/user", + stateDir: "/home/user/.openclaw", + configDir: "/home/user/.openclaw", + configPath: "/home/user/.openclaw/openclaw.json", + workspaceDir: null, + extensionsDir: null, + skillsDir: null, + hooksDir: null, + externalRoots: [], + warnings: [], + errors: [], + hasExternalConfig: false, + }; + + const bundle = createSnapshotBundle(hostState, logger, { persist: false }); + if (bundle === null) { + expect.unreachable("bundle should not be null"); + return; + } + + // Check the snapshot-level openclaw.json (not sandbox-bundle) + const snapshotConfigEntry = store.get(bundle.snapshotDir + "/openclaw/openclaw.json"); + if (!snapshotConfigEntry?.content) { + expect.unreachable("snapshot config entry should exist with content"); + return; + } + const snapshotConfig = JSON.parse(snapshotConfigEntry.content); + expect(snapshotConfig).not.toHaveProperty("gateway"); + expect(snapshotConfig.nvidia.apiKey).toBe("[STRIPPED_BY_MIGRATION]"); + expect(snapshotConfig.version).toBe(1); + }); }); // ------------------------------------------------------------------------- @@ -896,5 +1185,236 @@ describe("commands/migration-state", () => { } } }); + + it("restore succeeds when blueprint digest matches", () => { + const logger = makeLogger(); + const origHome = process.env.HOME; + process.env.HOME = "/home/user"; + try { + const blueprintContent = "version: 0.1.0\ndigest: ''\n"; + addFile("/test/blueprint.yaml", blueprintContent); + + // First create a snapshot with blueprintPath to get the real digest + addDir("/home/user/.openclaw"); + addFile("/home/user/.openclaw/openclaw.json", JSON.stringify({ version: 1 })); + const hostState: HostOpenClawState = { + exists: true, + homeDir: "/home/user", + stateDir: "/home/user/.openclaw", + configDir: "/home/user/.openclaw", + configPath: "/home/user/.openclaw/openclaw.json", + workspaceDir: null, + extensionsDir: null, + skillsDir: null, + hooksDir: null, + externalRoots: [], + warnings: [], + errors: [], + hasExternalConfig: false, + }; + const bundle = createSnapshotBundle(hostState, logger, { + persist: false, + blueprintPath: "/test/blueprint.yaml", + }); + if (bundle === null) { + expect.unreachable("bundle should not be null"); + return; + } + const digest = bundle.manifest.blueprintDigest; + expect(digest).toBeTruthy(); + + // Now set up for restore with matching digest + store.clear(); + const manifest: SnapshotManifest = { + version: 3, + createdAt: "2026-03-01T00:00:00.000Z", + homeDir: "/home/user", + stateDir: "/home/user/.openclaw", + configPath: null, + hasExternalConfig: false, + externalRoots: [], + warnings: [], + blueprintDigest: digest, + }; + addFile("/snapshots/snap1/snapshot.json", JSON.stringify(manifest)); + addDir("/snapshots/snap1/openclaw"); + addFile("/snapshots/snap1/openclaw/openclaw.json", JSON.stringify({ restored: true })); + addFile("/test/blueprint.yaml", blueprintContent); + + const result = restoreSnapshotToHost("/snapshots/snap1", logger, { + blueprintPath: "/test/blueprint.yaml", + }); + expect(result).toBe(true); + } finally { + if (origHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = origHome; + } + } + }); + + it("restore fails when blueprint digest mismatches", () => { + const logger = makeLogger(); + const origHome = process.env.HOME; + process.env.HOME = "/home/user"; + try { + const manifest: SnapshotManifest = { + version: 3, + createdAt: "2026-03-01T00:00:00.000Z", + homeDir: "/home/user", + stateDir: "/home/user/.openclaw", + configPath: null, + hasExternalConfig: false, + externalRoots: [], + warnings: [], + blueprintDigest: "wrong-hash-value", + }; + addFile("/snapshots/snap1/snapshot.json", JSON.stringify(manifest)); + addDir("/snapshots/snap1/openclaw"); + addFile("/test/blueprint.yaml", "version: 0.1.0\n"); + + const result = restoreSnapshotToHost("/snapshots/snap1", logger, { + blueprintPath: "/test/blueprint.yaml", + }); + expect(result).toBe(false); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("digest mismatch")); + } finally { + if (origHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = origHome; + } + } + }); + + it("restore fails when manifest has empty string blueprintDigest", () => { + const logger = makeLogger(); + const origHome = process.env.HOME; + process.env.HOME = "/home/user"; + try { + const manifest: SnapshotManifest = { + version: 3, + createdAt: "2026-03-01T00:00:00.000Z", + homeDir: "/home/user", + stateDir: "/home/user/.openclaw", + configPath: null, + hasExternalConfig: false, + externalRoots: [], + warnings: [], + blueprintDigest: "", + }; + addFile("/snapshots/snap1/snapshot.json", JSON.stringify(manifest)); + addDir("/snapshots/snap1/openclaw"); + + const result = restoreSnapshotToHost("/snapshots/snap1", logger); + expect(result).toBe(false); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining("invalid blueprintDigest"), + ); + } finally { + if (origHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = origHome; + } + } + }); + + it("restore fails when manifest has digest but no blueprintPath provided", () => { + const logger = makeLogger(); + const origHome = process.env.HOME; + process.env.HOME = "/home/user"; + try { + const manifest: SnapshotManifest = { + version: 3, + createdAt: "2026-03-01T00:00:00.000Z", + homeDir: "/home/user", + stateDir: "/home/user/.openclaw", + configPath: null, + hasExternalConfig: false, + externalRoots: [], + warnings: [], + blueprintDigest: "abc123", + }; + addFile("/snapshots/snap1/snapshot.json", JSON.stringify(manifest)); + addDir("/snapshots/snap1/openclaw"); + + const result = restoreSnapshotToHost("/snapshots/snap1", logger); + expect(result).toBe(false); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining("no blueprint is available"), + ); + } finally { + if (origHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = origHome; + } + } + }); + + it("restore succeeds when manifest has no blueprintDigest (backward compat)", () => { + const logger = makeLogger(); + const origHome = process.env.HOME; + process.env.HOME = "/home/user"; + try { + const manifest: SnapshotManifest = { + version: 2, + createdAt: "2026-03-01T00:00:00.000Z", + homeDir: "/home/user", + stateDir: "/home/user/.openclaw", + configPath: null, + hasExternalConfig: false, + externalRoots: [], + warnings: [], + // no blueprintDigest field + }; + addFile("/snapshots/snap1/snapshot.json", JSON.stringify(manifest)); + addDir("/snapshots/snap1/openclaw"); + addFile("/snapshots/snap1/openclaw/openclaw.json", JSON.stringify({ restored: true })); + + const result = restoreSnapshotToHost("/snapshots/snap1", logger); + expect(result).toBe(true); + } finally { + if (origHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = origHome; + } + } + }); + + it("restore succeeds for v3 snapshot created without blueprintPath", () => { + const logger = makeLogger(); + const origHome = process.env.HOME; + process.env.HOME = "/home/user"; + try { + // v3 manifest with no blueprintDigest field — created without a blueprint + const manifest: SnapshotManifest = { + version: 3, + createdAt: "2026-03-01T00:00:00.000Z", + homeDir: "/home/user", + stateDir: "/home/user/.openclaw", + configPath: null, + hasExternalConfig: false, + externalRoots: [], + warnings: [], + // blueprintDigest intentionally omitted + }; + addFile("/snapshots/snap1/snapshot.json", JSON.stringify(manifest)); + addDir("/snapshots/snap1/openclaw"); + addFile("/snapshots/snap1/openclaw/openclaw.json", JSON.stringify({ restored: true })); + + const result = restoreSnapshotToHost("/snapshots/snap1", logger); + expect(result).toBe(true); + } finally { + if (origHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = origHome; + } + } + }); }); }); diff --git a/nemoclaw/src/commands/migration-state.ts b/nemoclaw/src/commands/migration-state.ts index 258ac89ec..4a4345e66 100644 --- a/nemoclaw/src/commands/migration-state.ts +++ b/nemoclaw/src/commands/migration-state.ts @@ -17,11 +17,12 @@ import { import os from "node:os"; import path from "node:path"; import { create as createTar } from "tar"; +import { createHash } from "node:crypto"; import JSON5 from "json5"; import type { PluginLogger } from "../index.js"; const SANDBOX_MIGRATION_DIR = "/sandbox/.nemoclaw/migration"; -const SNAPSHOT_VERSION = 2; +const SNAPSHOT_VERSION = 3; export type MigrationRootKind = "workspace" | "agentDir" | "skillsExtraDir"; @@ -65,6 +66,7 @@ export interface SnapshotManifest { hasExternalConfig: boolean; externalRoots: MigrationExternalRoot[]; warnings: string[]; + blueprintDigest?: string | null; } export interface SnapshotBundle { @@ -474,9 +476,98 @@ export function detectHostOpenClaw(env: NodeJS.ProcessEnv = process.env): HostOp }; } -function copyDirectory(sourcePath: string, destinationPath: string): void { +// --------------------------------------------------------------------------- +// Credential sanitization +// --------------------------------------------------------------------------- + +/** + * Basenames that MUST NOT be copied into snapshot bundles. + * These files contain credential references or session tokens + * that should never cross the sandbox boundary. + */ +const CREDENTIAL_SENSITIVE_BASENAMES = new Set(["auth-profiles.json"]); + +/** + * Credential field names that MUST be stripped from config files + * before they enter the sandbox. Credentials should be injected + * at runtime via OpenShell's provider credential mechanism. + */ +const CREDENTIAL_FIELDS = new Set([ + "apiKey", + "api_key", + "token", + "secret", + "password", + "resolvedKey", +]); + +/** + * Pattern-based detection for credential field names not covered by the + * explicit set above. Matches common suffixes like accessToken, privateKey, + * clientSecret, etc. + */ +const CREDENTIAL_FIELD_PATTERN = + /(?:access|refresh|client|bearer|auth|api|private|public|signing|session)(?:Token|Key|Secret|Password)$/; + +function isCredentialField(key: string): boolean { + return CREDENTIAL_FIELDS.has(key) || CREDENTIAL_FIELD_PATTERN.test(key); +} + +/** + * Recursively strip credential fields from a JSON-like object. + * Returns a new object with sensitive values replaced by a placeholder. + */ +function stripCredentials(obj: unknown): unknown { + if (obj === null || obj === undefined) return obj; + if (typeof obj !== "object") return obj; + if (Array.isArray(obj)) return obj.map(stripCredentials); + + const result: Record = {}; + for (const [key, value] of Object.entries(obj as Record)) { + if (isCredentialField(key)) { + result[key] = "[STRIPPED_BY_MIGRATION]"; + } else { + result[key] = stripCredentials(value); + } + } + return result; +} + +/** + * Strip credential fields from openclaw.json and remove the gateway + * config section (contains auth tokens — regenerated by sandbox entrypoint). + */ +function sanitizeConfigFile(configPath: string): void { + if (!existsSync(configPath)) return; + const raw = readFileSync(configPath, "utf-8"); + const parsed: unknown = JSON5.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return; + const config = parsed as Record; + delete config["gateway"]; + const sanitized = stripCredentials(config) as Record; + writeFileSync(configPath, JSON.stringify(sanitized, null, 2)); + chmodSync(configPath, 0o600); +} + +function computeFileDigest(filePath: string): string { + if (!existsSync(filePath)) { + throw new Error(`Blueprint file not found: ${filePath}`); + } + return createHash("sha256").update(readFileSync(filePath)).digest("hex"); +} + +// --------------------------------------------------------------------------- + +function copyDirectory( + sourcePath: string, + destinationPath: string, + options?: { stripCredentials?: boolean }, +): void { cpSync(sourcePath, destinationPath, { recursive: true, + filter: options?.stripCredentials + ? (source: string) => !CREDENTIAL_SENSITIVE_BASENAMES.has(path.basename(source).toLowerCase()) + : undefined, }); } @@ -549,7 +640,7 @@ function prepareSandboxState(snapshotDir: string, manifest: SnapshotManifest): s const preparedStateDir = path.join(snapshotDir, "sandbox-bundle", "openclaw"); rmSync(preparedStateDir, { recursive: true, force: true }); mkdirSync(path.dirname(preparedStateDir), { recursive: true }); - copyDirectory(path.join(snapshotDir, "openclaw"), preparedStateDir); + copyDirectory(path.join(snapshotDir, "openclaw"), preparedStateDir, { stripCredentials: true }); const configSourcePath = resolveConfigSourcePath(manifest, snapshotDir); const config = existsSync(configSourcePath) ? (loadConfigDocument(configSourcePath) ?? {}) : {}; @@ -560,16 +651,26 @@ function prepareSandboxState(snapshotDir: string, manifest: SnapshotManifest): s } } + // Strip gateway config (contains auth tokens) — sandbox entrypoint regenerates it + delete (config as Record)["gateway"]; + const configPath = path.join(preparedStateDir, "openclaw.json"); writeFileSync(configPath, JSON.stringify(config, null, 2)); chmodSync(configPath, 0o600); + + // SECURITY: Strip all credentials from the bundle before it enters the sandbox. + // Credentials must be injected at runtime via OpenShell's provider credential + // mechanism, not baked into the sandbox filesystem where a compromised agent + // can read them. + sanitizeConfigFile(configPath); + return preparedStateDir; } export function createSnapshotBundle( hostState: HostOpenClawState, logger: PluginLogger, - options: { persist: boolean }, + options: { persist: boolean; blueprintPath?: string }, ): SnapshotBundle | null { if (!hostState.stateDir || !hostState.homeDir) { logger.error("Cannot snapshot host OpenClaw state: no state directory was resolved."); @@ -587,21 +688,22 @@ export function createSnapshotBundle( try { mkdirSync(parentDir, { recursive: true }); const snapshotStateDir = path.join(parentDir, "openclaw"); - copyDirectory(hostState.stateDir, snapshotStateDir); + copyDirectory(hostState.stateDir, snapshotStateDir, { stripCredentials: true }); + sanitizeConfigFile(path.join(snapshotStateDir, "openclaw.json")); if (hostState.configPath && hostState.hasExternalConfig) { const configSnapshotDir = path.join(parentDir, "config"); mkdirSync(configSnapshotDir, { recursive: true }); const configSnapshotPath = path.join(configSnapshotDir, "openclaw.json"); copyFileSync(hostState.configPath, configSnapshotPath); - chmodSync(configSnapshotPath, 0o600); + sanitizeConfigFile(configSnapshotPath); } const externalRoots: MigrationExternalRoot[] = []; for (const root of hostState.externalRoots) { const destination = path.join(parentDir, root.snapshotRelativePath); mkdirSync(path.dirname(destination), { recursive: true }); - copyDirectory(root.sourcePath, destination); + copyDirectory(root.sourcePath, destination, { stripCredentials: true }); externalRoots.push({ ...root, symlinkPaths: collectSymlinkPaths(root.sourcePath), @@ -619,6 +721,10 @@ export function createSnapshotBundle( warnings: hostState.warnings, }; + if (options.blueprintPath !== undefined) { + manifest.blueprintDigest = computeFileDigest(options.blueprintPath); + } + writeSnapshotManifest(parentDir, manifest); return { @@ -663,7 +769,11 @@ export function loadSnapshotManifest(snapshotDir: string): SnapshotManifest { return readSnapshotManifest(snapshotDir); } -export function restoreSnapshotToHost(snapshotDir: string, logger: PluginLogger): boolean { +export function restoreSnapshotToHost( + snapshotDir: string, + logger: PluginLogger, + options?: { blueprintPath?: string }, +): boolean { const manifest = readSnapshotManifest(snapshotDir); const snapshotStateDir = path.join(snapshotDir, "openclaw"); if (!existsSync(snapshotStateDir)) { @@ -740,6 +850,39 @@ export function restoreSnapshotToHost(snapshotDir: string, logger: PluginLogger) } } + // SECURITY: Validate blueprint digest when present in manifest + if ("blueprintDigest" in manifest) { + if (!manifest.blueprintDigest || typeof manifest.blueprintDigest !== "string") { + logger.error("Snapshot manifest has empty or invalid blueprintDigest. Refusing to restore."); + return false; + } + let currentDigest: string | null = null; + try { + currentDigest = options?.blueprintPath ? computeFileDigest(options.blueprintPath) : null; + } catch (err: unknown) { + logger.error( + `Failed to read blueprint for digest verification: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return false; + } + if (!currentDigest) { + logger.error( + "Snapshot contains a blueprintDigest but no blueprint is available for verification. " + + "Refusing to restore.", + ); + return false; + } + if (currentDigest !== manifest.blueprintDigest) { + logger.error( + `Blueprint digest mismatch. Snapshot was created with digest=${manifest.blueprintDigest} ` + + `but current blueprint has digest=${currentDigest}. Refusing to restore.`, + ); + return false; + } + } + try { if (existsSync(manifest.stateDir)) { const archiveName = `${manifest.stateDir}.nemoclaw-archived-${String(Date.now())}`; diff --git a/package-lock.json b/package-lock.json index 74a1d7654..c06820ed7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,9 +7,13 @@ "": { "name": "nemoclaw", "version": "0.1.0", + "bundleDependencies": [ + "p-retry" + ], "license": "Apache-2.0", "dependencies": { - "openclaw": "2026.3.11" + "p-retry": "^4.6.2", + "yaml": "^2.8.3" }, "bin": { "nemoclaw": "bin/nemoclaw.js" @@ -22,1376 +26,848 @@ "@types/node": "^25.5.0", "@vitest/coverage-v8": "^4.1.0", "eslint": "^10.1.0", + "execa": "^9.6.1", + "prettier": "^3.8.1", + "tsx": "^4.21.0", "typescript": "^6.0.2", "vitest": "^4.1.0" }, "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@agentclientprotocol/sdk": { - "version": "0.16.1", - "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.16.1.tgz", - "integrity": "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw==", - "license": "Apache-2.0", - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" + "node": ">=22.16.0" } }, - "node_modules/@anthropic-ai/sdk": { - "version": "0.73.0", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.73.0.tgz", - "integrity": "sha512-URURVzhxXGJDGUGFunIOtBlSl7KWvZiAAKY/ttTkZAkXT9bTPqdk2eK0b8qqSxXpikh3QKPnPYpiyX98zf5ebw==", + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, "license": "MIT", "dependencies": { - "json-schema-to-ts": "^3.1.1" - }, - "bin": { - "anthropic-ai-sdk": "bin/cli" - }, - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" - }, - "peerDependenciesMeta": { - "zod": { - "optional": true - } - } - }, - "node_modules/@aws-crypto/crc32": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", - "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" }, "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-crypto/sha256-browser": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", - "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-js": "^5.2.0", - "@aws-crypto/supports-web-crypto": "^5.2.0", - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" + "node": ">=6.9.0" } }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=14.0.0" + "node": ">=6.9.0" } }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=14.0.0" + "node": ">=6.9.0" } }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "license": "Apache-2.0", + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" }, "engines": { - "node": ">=14.0.0" + "node": ">=6.0.0" } }, - "node_modules/@aws-crypto/sha256-js": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", - "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", - "license": "Apache-2.0", + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-crypto/supports-web-crypto": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", - "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" + "node": ">=6.9.0" } }, - "node_modules/@aws-crypto/util": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", - "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.222.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" } }, - "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "license": "Apache-2.0", + "node_modules/@commitlint/cli": { + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-20.5.0.tgz", + "integrity": "sha512-yNkyN/tuKTJS3wdVfsZ2tXDM4G4Gi7z+jW54Cki8N8tZqwKBltbIvUUrSbT4hz1bhW/h0CdR+5sCSpXD+wMKaQ==", + "dev": true, + "license": "MIT", "dependencies": { - "tslib": "^2.6.2" + "@commitlint/format": "^20.5.0", + "@commitlint/lint": "^20.5.0", + "@commitlint/load": "^20.5.0", + "@commitlint/read": "^20.5.0", + "@commitlint/types": "^20.5.0", + "tinyexec": "^1.0.0", + "yargs": "^17.0.0" + }, + "bin": { + "commitlint": "cli.js" }, "engines": { - "node": ">=14.0.0" + "node": ">=v18" } }, - "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "license": "Apache-2.0", + "node_modules/@commitlint/config-conventional": { + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-20.5.0.tgz", + "integrity": "sha512-t3Ni88rFw1XMa4nZHgOKJ8fIAT9M2j5TnKyTqJzsxea7FUetlNdYFus9dz+MhIRZmc16P0PPyEfh6X2d/qw8SA==", + "dev": true, + "license": "MIT", "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" + "@commitlint/types": "^20.5.0", + "conventional-changelog-conventionalcommits": "^9.2.0" }, "engines": { - "node": ">=14.0.0" + "node": ">=v18" } }, - "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "license": "Apache-2.0", + "node_modules/@commitlint/config-validator": { + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-20.5.0.tgz", + "integrity": "sha512-T/Uh6iJUzyx7j35GmHWdIiGRQB+ouZDk0pwAaYq4SXgB54KZhFdJ0vYmxiW6AMYICTIWuyMxDBl1jK74oFp/Gw==", + "dev": true, + "license": "MIT", "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" + "@commitlint/types": "^20.5.0", + "ajv": "^8.11.0" }, "engines": { - "node": ">=14.0.0" + "node": ">=v18" } }, - "node_modules/@aws-sdk/client-bedrock": { - "version": "3.1009.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock/-/client-bedrock-3.1009.0.tgz", - "integrity": "sha512-KzLNqSg1T59sSlQvEA4EL3oDIAMidM54AB1b+UGouPFuUrrwGp2uUlZUYzIIlCvqpf7wEDh8wypqXISRItkgdg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.20", - "@aws-sdk/credential-provider-node": "^3.972.21", - "@aws-sdk/middleware-host-header": "^3.972.8", - "@aws-sdk/middleware-logger": "^3.972.8", - "@aws-sdk/middleware-recursion-detection": "^3.972.8", - "@aws-sdk/middleware-user-agent": "^3.972.21", - "@aws-sdk/region-config-resolver": "^3.972.8", - "@aws-sdk/token-providers": "3.1009.0", - "@aws-sdk/types": "^3.973.6", - "@aws-sdk/util-endpoints": "^3.996.5", - "@aws-sdk/util-user-agent-browser": "^3.972.8", - "@aws-sdk/util-user-agent-node": "^3.973.7", - "@smithy/config-resolver": "^4.4.11", - "@smithy/core": "^3.23.11", - "@smithy/fetch-http-handler": "^5.3.15", - "@smithy/hash-node": "^4.2.12", - "@smithy/invalid-dependency": "^4.2.12", - "@smithy/middleware-content-length": "^4.2.12", - "@smithy/middleware-endpoint": "^4.4.25", - "@smithy/middleware-retry": "^4.4.42", - "@smithy/middleware-serde": "^4.2.14", - "@smithy/middleware-stack": "^4.2.12", - "@smithy/node-config-provider": "^4.3.12", - "@smithy/node-http-handler": "^4.4.16", - "@smithy/protocol-http": "^5.3.12", - "@smithy/smithy-client": "^4.12.5", - "@smithy/types": "^4.13.1", - "@smithy/url-parser": "^4.2.12", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.41", - "@smithy/util-defaults-mode-node": "^4.2.44", - "@smithy/util-endpoints": "^3.3.3", - "@smithy/util-middleware": "^4.2.12", - "@smithy/util-retry": "^4.2.12", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/client-bedrock-runtime": { - "version": "3.1009.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1009.0.tgz", - "integrity": "sha512-0k9d0oO6nw3Y6jtgs1cmMPNuwAVPQahIoshKK3NDfhVQR1wNC90/gSpdfa9GKswe8XRq/ZZlq7ny0qM1rd/Hkg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.20", - "@aws-sdk/credential-provider-node": "^3.972.21", - "@aws-sdk/eventstream-handler-node": "^3.972.11", - "@aws-sdk/middleware-eventstream": "^3.972.8", - "@aws-sdk/middleware-host-header": "^3.972.8", - "@aws-sdk/middleware-logger": "^3.972.8", - "@aws-sdk/middleware-recursion-detection": "^3.972.8", - "@aws-sdk/middleware-user-agent": "^3.972.21", - "@aws-sdk/middleware-websocket": "^3.972.13", - "@aws-sdk/region-config-resolver": "^3.972.8", - "@aws-sdk/token-providers": "3.1009.0", - "@aws-sdk/types": "^3.973.6", - "@aws-sdk/util-endpoints": "^3.996.5", - "@aws-sdk/util-user-agent-browser": "^3.972.8", - "@aws-sdk/util-user-agent-node": "^3.973.7", - "@smithy/config-resolver": "^4.4.11", - "@smithy/core": "^3.23.11", - "@smithy/eventstream-serde-browser": "^4.2.12", - "@smithy/eventstream-serde-config-resolver": "^4.3.12", - "@smithy/eventstream-serde-node": "^4.2.12", - "@smithy/fetch-http-handler": "^5.3.15", - "@smithy/hash-node": "^4.2.12", - "@smithy/invalid-dependency": "^4.2.12", - "@smithy/middleware-content-length": "^4.2.12", - "@smithy/middleware-endpoint": "^4.4.25", - "@smithy/middleware-retry": "^4.4.42", - "@smithy/middleware-serde": "^4.2.14", - "@smithy/middleware-stack": "^4.2.12", - "@smithy/node-config-provider": "^4.3.12", - "@smithy/node-http-handler": "^4.4.16", - "@smithy/protocol-http": "^5.3.12", - "@smithy/smithy-client": "^4.12.5", - "@smithy/types": "^4.13.1", - "@smithy/url-parser": "^4.2.12", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.41", - "@smithy/util-defaults-mode-node": "^4.2.44", - "@smithy/util-endpoints": "^3.3.3", - "@smithy/util-middleware": "^4.2.12", - "@smithy/util-retry": "^4.2.12", - "@smithy/util-stream": "^4.5.19", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/core": { - "version": "3.973.20", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.20.tgz", - "integrity": "sha512-i3GuX+lowD892F3IuJf8o6AbyDupMTdyTxQrCJGcn71ni5hTZ82L4nQhcdumxZ7XPJRJJVHS/CR3uYOIIs0PVA==", - "license": "Apache-2.0", + "node_modules/@commitlint/ensure": { + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-20.5.0.tgz", + "integrity": "sha512-IpHqAUesBeW1EDDdjzJeaOxU9tnogLAyXLRBn03SHlj1SGENn2JGZqSWGkFvBJkJzfXAuCNtsoYzax+ZPS+puw==", + "dev": true, + "license": "MIT", "dependencies": { - "@aws-sdk/types": "^3.973.6", - "@aws-sdk/xml-builder": "^3.972.11", - "@smithy/core": "^3.23.11", - "@smithy/node-config-provider": "^4.3.12", - "@smithy/property-provider": "^4.2.12", - "@smithy/protocol-http": "^5.3.12", - "@smithy/signature-v4": "^5.3.12", - "@smithy/smithy-client": "^4.12.5", - "@smithy/types": "^4.13.1", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-middleware": "^4.2.12", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" + "@commitlint/types": "^20.5.0", + "lodash.camelcase": "^4.3.0", + "lodash.kebabcase": "^4.1.1", + "lodash.snakecase": "^4.1.1", + "lodash.startcase": "^4.4.0", + "lodash.upperfirst": "^4.3.1" }, "engines": { - "node": ">=20.0.0" + "node": ">=v18" } }, - "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.18", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.18.tgz", - "integrity": "sha512-X0B8AlQY507i5DwjLByeU2Af4ARsl9Vr84koDcXCbAkplmU+1xBFWxEPrWRAoh56waBne/yJqEloSwvRf4x6XA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.20", - "@aws-sdk/types": "^3.973.6", - "@smithy/property-provider": "^4.2.12", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, + "node_modules/@commitlint/execute-rule": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-20.0.0.tgz", + "integrity": "sha512-xyCoOShoPuPL44gVa+5EdZsBVao/pNzpQhkzq3RdtlFdKZtjWcLlUFQHSWBuhk5utKYykeJPSz2i8ABHQA+ZZw==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=20.0.0" + "node": ">=v18" } }, - "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.20", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.20.tgz", - "integrity": "sha512-ey9Lelj001+oOfrbKmS6R2CJAiXX7QKY4Vj9VJv6L2eE6/VjD8DocHIoYqztTm70xDLR4E1jYPTKfIui+eRNDA==", - "license": "Apache-2.0", + "node_modules/@commitlint/format": { + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-20.5.0.tgz", + "integrity": "sha512-TI9EwFU/qZWSK7a5qyXMpKPPv3qta7FO4tKW+Wt2al7sgMbLWTsAcDpX1cU8k16TRdsiiet9aOw0zpvRXNJu7Q==", + "dev": true, + "license": "MIT", "dependencies": { - "@aws-sdk/core": "^3.973.20", - "@aws-sdk/types": "^3.973.6", - "@smithy/fetch-http-handler": "^5.3.15", - "@smithy/node-http-handler": "^4.4.16", - "@smithy/property-provider": "^4.2.12", - "@smithy/protocol-http": "^5.3.12", - "@smithy/smithy-client": "^4.12.5", - "@smithy/types": "^4.13.1", - "@smithy/util-stream": "^4.5.19", - "tslib": "^2.6.2" + "@commitlint/types": "^20.5.0", + "picocolors": "^1.1.1" }, "engines": { - "node": ">=20.0.0" + "node": ">=v18" } }, - "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.20", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.20.tgz", - "integrity": "sha512-5flXSnKHMloObNF+9N0cupKegnH1Z37cdVlpETVgx8/rAhCe+VNlkcZH3HDg2SDn9bI765S+rhNPXGDJJPfbtA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.20", - "@aws-sdk/credential-provider-env": "^3.972.18", - "@aws-sdk/credential-provider-http": "^3.972.20", - "@aws-sdk/credential-provider-login": "^3.972.20", - "@aws-sdk/credential-provider-process": "^3.972.18", - "@aws-sdk/credential-provider-sso": "^3.972.20", - "@aws-sdk/credential-provider-web-identity": "^3.972.20", - "@aws-sdk/nested-clients": "^3.996.10", - "@aws-sdk/types": "^3.973.6", - "@smithy/credential-provider-imds": "^4.2.12", - "@smithy/property-provider": "^4.2.12", - "@smithy/shared-ini-file-loader": "^4.4.7", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.20", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.20.tgz", - "integrity": "sha512-gEWo54nfqp2jABMu6HNsjVC4hDLpg9HC8IKSJnp0kqWtxIJYHTmiLSsIfI4ScQjxEwpB+jOOH8dOLax1+hy/Hw==", - "license": "Apache-2.0", + "node_modules/@commitlint/is-ignored": { + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-20.5.0.tgz", + "integrity": "sha512-JWLarAsurHJhPozbuAH6GbP4p/hdOCoqS9zJMfqwswne+/GPs5V0+rrsfOkP68Y8PSLphwtFXV0EzJ+GTXTTGg==", + "dev": true, + "license": "MIT", "dependencies": { - "@aws-sdk/core": "^3.973.20", - "@aws-sdk/nested-clients": "^3.996.10", - "@aws-sdk/types": "^3.973.6", - "@smithy/property-provider": "^4.2.12", - "@smithy/protocol-http": "^5.3.12", - "@smithy/shared-ini-file-loader": "^4.4.7", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" + "@commitlint/types": "^20.5.0", + "semver": "^7.6.0" }, "engines": { - "node": ">=20.0.0" + "node": ">=v18" } }, - "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.21", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.21.tgz", - "integrity": "sha512-hah8if3/B/Q+LBYN5FukyQ1Mym6PLPDsBOBsIgNEYD6wLyZg0UmUF/OKIVC3nX9XH8TfTPuITK+7N/jenVACWA==", - "license": "Apache-2.0", + "node_modules/@commitlint/lint": { + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-20.5.0.tgz", + "integrity": "sha512-jiM3hNUdu04jFBf1VgPdjtIPvbuVfDTBAc6L98AWcoLjF5sYqkulBHBzlVWll4rMF1T5zeQFB6r//a+s+BBKlA==", + "dev": true, + "license": "MIT", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.18", - "@aws-sdk/credential-provider-http": "^3.972.20", - "@aws-sdk/credential-provider-ini": "^3.972.20", - "@aws-sdk/credential-provider-process": "^3.972.18", - "@aws-sdk/credential-provider-sso": "^3.972.20", - "@aws-sdk/credential-provider-web-identity": "^3.972.20", - "@aws-sdk/types": "^3.973.6", - "@smithy/credential-provider-imds": "^4.2.12", - "@smithy/property-provider": "^4.2.12", - "@smithy/shared-ini-file-loader": "^4.4.7", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" + "@commitlint/is-ignored": "^20.5.0", + "@commitlint/parse": "^20.5.0", + "@commitlint/rules": "^20.5.0", + "@commitlint/types": "^20.5.0" }, "engines": { - "node": ">=20.0.0" + "node": ">=v18" } }, - "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.18", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.18.tgz", - "integrity": "sha512-Tpl7SRaPoOLT32jbTWchPsn52hYYgJ0kpiFgnwk8pxTANQdUymVSZkzFvv1+oOgZm1CrbQUP9MBeoMZ9IzLZjA==", - "license": "Apache-2.0", + "node_modules/@commitlint/load": { + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-20.5.0.tgz", + "integrity": "sha512-sLhhYTL/KxeOTZjjabKDhwidGZan84XKK1+XFkwDYL/4883kIajcz/dZFAhBJmZPtL8+nBx6bnkzA95YxPeDPw==", + "dev": true, + "license": "MIT", "dependencies": { - "@aws-sdk/core": "^3.973.20", - "@aws-sdk/types": "^3.973.6", - "@smithy/property-provider": "^4.2.12", - "@smithy/shared-ini-file-loader": "^4.4.7", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" + "@commitlint/config-validator": "^20.5.0", + "@commitlint/execute-rule": "^20.0.0", + "@commitlint/resolve-extends": "^20.5.0", + "@commitlint/types": "^20.5.0", + "cosmiconfig": "^9.0.1", + "cosmiconfig-typescript-loader": "^6.1.0", + "is-plain-obj": "^4.1.0", + "lodash.mergewith": "^4.6.2", + "picocolors": "^1.1.1" }, "engines": { - "node": ">=20.0.0" + "node": ">=v18" } }, - "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.20", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.20.tgz", - "integrity": "sha512-p+R+PYR5Z7Gjqf/6pvbCnzEHcqPCpLzR7Yf127HjJ6EAb4hUcD+qsNRnuww1sB/RmSeCLxyay8FMyqREw4p1RA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.20", - "@aws-sdk/nested-clients": "^3.996.10", - "@aws-sdk/token-providers": "3.1009.0", - "@aws-sdk/types": "^3.973.6", - "@smithy/property-provider": "^4.2.12", - "@smithy/shared-ini-file-loader": "^4.4.7", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, + "node_modules/@commitlint/message": { + "version": "20.4.3", + "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-20.4.3.tgz", + "integrity": "sha512-6akwCYrzcrFcTYz9GyUaWlhisY4lmQ3KvrnabmhoeAV8nRH4dXJAh4+EUQ3uArtxxKQkvxJS78hNX2EU3USgxQ==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=20.0.0" + "node": ">=v18" } }, - "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.20", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.20.tgz", - "integrity": "sha512-rWCmh8o7QY4CsUj63qopzMzkDq/yPpkrpb+CnjBEFSOg/02T/we7sSTVg4QsDiVS9uwZ8VyONhq98qt+pIh3KA==", - "license": "Apache-2.0", + "node_modules/@commitlint/parse": { + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-20.5.0.tgz", + "integrity": "sha512-SeKWHBMk7YOTnnEWUhx+d1a9vHsjjuo6Uo1xRfPNfeY4bdYFasCH1dDpAv13Lyn+dDPOels+jP6D2GRZqzc5fA==", + "dev": true, + "license": "MIT", "dependencies": { - "@aws-sdk/core": "^3.973.20", - "@aws-sdk/nested-clients": "^3.996.10", - "@aws-sdk/types": "^3.973.6", - "@smithy/property-provider": "^4.2.12", - "@smithy/shared-ini-file-loader": "^4.4.7", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" + "@commitlint/types": "^20.5.0", + "conventional-changelog-angular": "^8.2.0", + "conventional-commits-parser": "^6.3.0" }, "engines": { - "node": ">=20.0.0" + "node": ">=v18" } }, - "node_modules/@aws-sdk/eventstream-handler-node": { - "version": "3.972.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.11.tgz", - "integrity": "sha512-2IrLrOruRr1NhTK0vguBL1gCWv1pu4bf4KaqpsA+/vCJpFEbvXFawn71GvCzk1wyjnDUsemtKypqoKGv4cSGbA==", - "license": "Apache-2.0", + "node_modules/@commitlint/read": { + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-20.5.0.tgz", + "integrity": "sha512-JDEIJ2+GnWpK8QqwfmW7O42h0aycJEWNqcdkJnyzLD11nf9dW2dWLTVEa8Wtlo4IZFGLPATjR5neA5QlOvIH1w==", + "dev": true, + "license": "MIT", "dependencies": { - "@aws-sdk/types": "^3.973.6", - "@smithy/eventstream-codec": "^4.2.12", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" + "@commitlint/top-level": "^20.4.3", + "@commitlint/types": "^20.5.0", + "git-raw-commits": "^5.0.0", + "minimist": "^1.2.8", + "tinyexec": "^1.0.0" }, "engines": { - "node": ">=20.0.0" + "node": ">=v18" } }, - "node_modules/@aws-sdk/middleware-eventstream": { - "version": "3.972.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.8.tgz", - "integrity": "sha512-r+oP+tbCxgqXVC3pu3MUVePgSY0ILMjA+aEwOosS77m3/DRbtvHrHwqvMcw+cjANMeGzJ+i0ar+n77KXpRA8RQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.6", - "@smithy/protocol-http": "^5.3.12", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-host-header": { - "version": "3.972.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.8.tgz", - "integrity": "sha512-wAr2REfKsqoKQ+OkNqvOShnBoh+nkPurDKW7uAeVSu6kUECnWlSJiPvnoqxGlfousEY/v9LfS9sNc46hjSYDIQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.6", - "@smithy/protocol-http": "^5.3.12", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-logger": { - "version": "3.972.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.8.tgz", - "integrity": "sha512-CWl5UCM57WUFaFi5kB7IBY1UmOeLvNZAZ2/OZ5l20ldiJ3TiIz1pC65gYj8X0BCPWkeR1E32mpsCk1L1I4n+lA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.6", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.972.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.8.tgz", - "integrity": "sha512-BnnvYs2ZEpdlmZ2PNlV2ZyQ8j8AEkMTjN79y/YA475ER1ByFYrkVR85qmhni8oeTaJcDqbx364wDpitDAA/wCA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.6", - "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/protocol-http": "^5.3.12", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.972.21", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.21.tgz", - "integrity": "sha512-62XRl1GDYPpkt7cx1AX1SPy9wgNE9Iw/NPuurJu4lmhCWS7sGKO+kS53TQ8eRmIxy3skmvNInnk0ZbWrU5Dpyg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.20", - "@aws-sdk/types": "^3.973.6", - "@aws-sdk/util-endpoints": "^3.996.5", - "@smithy/core": "^3.23.11", - "@smithy/protocol-http": "^5.3.12", - "@smithy/types": "^4.13.1", - "@smithy/util-retry": "^4.2.12", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-websocket": { - "version": "3.972.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.13.tgz", - "integrity": "sha512-Gp6EWIqHX5wmsOR5ZxWyyzEU8P0xBdSxkm6VHEwXwBqScKZ7QWRoj6ZmHpr+S44EYb5tuzGya4ottsogSu2W3A==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.6", - "@aws-sdk/util-format-url": "^3.972.8", - "@smithy/eventstream-codec": "^4.2.12", - "@smithy/eventstream-serde-browser": "^4.2.12", - "@smithy/fetch-http-handler": "^5.3.15", - "@smithy/protocol-http": "^5.3.12", - "@smithy/signature-v4": "^5.3.12", - "@smithy/types": "^4.13.1", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-hex-encoding": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@aws-sdk/nested-clients": { - "version": "3.996.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.10.tgz", - "integrity": "sha512-SlDol5Z+C7Ivnc2rKGqiqfSUmUZzY1qHfVs9myt/nxVwswgfpjdKahyTzLTx802Zfq0NFRs7AejwKzzzl5Co2w==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.20", - "@aws-sdk/middleware-host-header": "^3.972.8", - "@aws-sdk/middleware-logger": "^3.972.8", - "@aws-sdk/middleware-recursion-detection": "^3.972.8", - "@aws-sdk/middleware-user-agent": "^3.972.21", - "@aws-sdk/region-config-resolver": "^3.972.8", - "@aws-sdk/types": "^3.973.6", - "@aws-sdk/util-endpoints": "^3.996.5", - "@aws-sdk/util-user-agent-browser": "^3.972.8", - "@aws-sdk/util-user-agent-node": "^3.973.7", - "@smithy/config-resolver": "^4.4.11", - "@smithy/core": "^3.23.11", - "@smithy/fetch-http-handler": "^5.3.15", - "@smithy/hash-node": "^4.2.12", - "@smithy/invalid-dependency": "^4.2.12", - "@smithy/middleware-content-length": "^4.2.12", - "@smithy/middleware-endpoint": "^4.4.25", - "@smithy/middleware-retry": "^4.4.42", - "@smithy/middleware-serde": "^4.2.14", - "@smithy/middleware-stack": "^4.2.12", - "@smithy/node-config-provider": "^4.3.12", - "@smithy/node-http-handler": "^4.4.16", - "@smithy/protocol-http": "^5.3.12", - "@smithy/smithy-client": "^4.12.5", - "@smithy/types": "^4.13.1", - "@smithy/url-parser": "^4.2.12", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.41", - "@smithy/util-defaults-mode-node": "^4.2.44", - "@smithy/util-endpoints": "^3.3.3", - "@smithy/util-middleware": "^4.2.12", - "@smithy/util-retry": "^4.2.12", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/region-config-resolver": { - "version": "3.972.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.8.tgz", - "integrity": "sha512-1eD4uhTDeambO/PNIDVG19A6+v4NdD7xzwLHDutHsUqz0B+i661MwQB2eYO4/crcCvCiQG4SRm1k81k54FEIvw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.6", - "@smithy/config-resolver": "^4.4.11", - "@smithy/node-config-provider": "^4.3.12", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/token-providers": { - "version": "3.1009.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1009.0.tgz", - "integrity": "sha512-KCPLuTqN9u0Rr38Arln78fRG9KXpzsPWmof+PZzfAHMMQq2QED6YjQrkrfiH7PDefLWEposY1o4/eGwrmKA4JA==", - "license": "Apache-2.0", + "node_modules/@commitlint/resolve-extends": { + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-20.5.0.tgz", + "integrity": "sha512-3SHPWUW2v0tyspCTcfSsYml0gses92l6TlogwzvM2cbxDgmhSRc+fldDjvGkCXJrjSM87BBaWYTPWwwyASZRrg==", + "dev": true, + "license": "MIT", "dependencies": { - "@aws-sdk/core": "^3.973.20", - "@aws-sdk/nested-clients": "^3.996.10", - "@aws-sdk/types": "^3.973.6", - "@smithy/property-provider": "^4.2.12", - "@smithy/shared-ini-file-loader": "^4.4.7", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" + "@commitlint/config-validator": "^20.5.0", + "@commitlint/types": "^20.5.0", + "global-directory": "^4.0.1", + "import-meta-resolve": "^4.0.0", + "lodash.mergewith": "^4.6.2", + "resolve-from": "^5.0.0" }, "engines": { - "node": ">=20.0.0" + "node": ">=v18" } }, - "node_modules/@aws-sdk/types": { - "version": "3.973.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.6.tgz", - "integrity": "sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw==", - "license": "Apache-2.0", + "node_modules/@commitlint/rules": { + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-20.5.0.tgz", + "integrity": "sha512-5NdQXQEdnDPT5pK8O39ZA7HohzPRHEsDGU23cyVCNPQy4WegAbAwrQk3nIu7p2sl3dutPk8RZd91yKTrMTnRkQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" + "@commitlint/ensure": "^20.5.0", + "@commitlint/message": "^20.4.3", + "@commitlint/to-lines": "^20.0.0", + "@commitlint/types": "^20.5.0" }, "engines": { - "node": ">=20.0.0" + "node": ">=v18" } }, - "node_modules/@aws-sdk/util-endpoints": { - "version": "3.996.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.5.tgz", - "integrity": "sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.6", - "@smithy/types": "^4.13.1", - "@smithy/url-parser": "^4.2.12", - "@smithy/util-endpoints": "^3.3.3", - "tslib": "^2.6.2" - }, + "node_modules/@commitlint/to-lines": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/to-lines/-/to-lines-20.0.0.tgz", + "integrity": "sha512-2l9gmwiCRqZNWgV+pX1X7z4yP0b3ex/86UmUFgoRt672Ez6cAM2lOQeHFRUTuE6sPpi8XBCGnd8Kh3bMoyHwJw==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=20.0.0" + "node": ">=v18" } }, - "node_modules/@aws-sdk/util-format-url": { - "version": "3.972.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.972.8.tgz", - "integrity": "sha512-J6DS9oocrgxM8xlUTTmQOuwRF6rnAGEujAN9SAzllcrQmwn5iJ58ogxy3SEhD0Q7JZvlA5jvIXBkpQRqEqlE9A==", - "license": "Apache-2.0", + "node_modules/@commitlint/top-level": { + "version": "20.4.3", + "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-20.4.3.tgz", + "integrity": "sha512-qD9xfP6dFg5jQ3NMrOhG0/w5y3bBUsVGyJvXxdWEwBm8hyx4WOk3kKXw28T5czBYvyeCVJgJJ6aoJZUWDpaacQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@aws-sdk/types": "^3.973.6", - "@smithy/querystring-builder": "^4.2.12", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" + "escalade": "^3.2.0" }, "engines": { - "node": ">=20.0.0" + "node": ">=v18" } }, - "node_modules/@aws-sdk/util-locate-window": { - "version": "3.965.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", - "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", - "license": "Apache-2.0", + "node_modules/@commitlint/types": { + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-20.5.0.tgz", + "integrity": "sha512-ZJoS8oSq2CAZEpc/YI9SulLrdiIyXeHb/OGqGrkUP6Q7YV+0ouNAa7GjqRdXeQPncHQIDz/jbCTlHScvYvO/gA==", + "dev": true, + "license": "MIT", "dependencies": { - "tslib": "^2.6.2" + "conventional-commits-parser": "^6.3.0", + "picocolors": "^1.1.1" }, "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.972.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.8.tgz", - "integrity": "sha512-B3KGXJviV2u6Cdw2SDY2aDhoJkVfY/Q/Trwk2CMSkikE1Oi6gRzxhvhIfiRpHfmIsAhV4EA54TVEX8K6CbHbkA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.6", - "@smithy/types": "^4.13.1", - "bowser": "^2.11.0", - "tslib": "^2.6.2" + "node": ">=v18" } }, - "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.973.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.7.tgz", - "integrity": "sha512-Hz6EZMUAEzqUd7e+vZ9LE7mn+5gMbxltXy18v+YSFY+9LBJz15wkNZvw5JqfX3z0FS9n3bgUtz3L5rAsfh4YlA==", - "license": "Apache-2.0", + "node_modules/@conventional-changelog/git-client": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@conventional-changelog/git-client/-/git-client-2.6.0.tgz", + "integrity": "sha512-T+uPDciKf0/ioNNDpMGc8FDsehJClZP0yR3Q5MN6wE/Y/1QZ7F+80OgznnTCOlMEG4AV0LvH2UJi3C/nBnaBUg==", + "dev": true, + "license": "MIT", "dependencies": { - "@aws-sdk/middleware-user-agent": "^3.972.21", - "@aws-sdk/types": "^3.973.6", - "@smithy/node-config-provider": "^4.3.12", - "@smithy/types": "^4.13.1", - "@smithy/util-config-provider": "^4.2.2", - "tslib": "^2.6.2" + "@simple-libs/child-process-utils": "^1.0.0", + "@simple-libs/stream-utils": "^1.2.0", + "semver": "^7.5.2" }, "engines": { - "node": ">=20.0.0" + "node": ">=18" }, "peerDependencies": { - "aws-crt": ">=1.0.0" + "conventional-commits-filter": "^5.0.0", + "conventional-commits-parser": "^6.3.0" }, "peerDependenciesMeta": { - "aws-crt": { + "conventional-commits-filter": { + "optional": true + }, + "conventional-commits-parser": { "optional": true } } }, - "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.11.tgz", - "integrity": "sha512-iitV/gZKQMvY9d7ovmyFnFuTHbBAtrmLnvaSb/3X8vOKyevwtpmEtyc8AdhVWZe0pI/1GsHxlEvQeOePFzy7KQ==", - "license": "Apache-2.0", + "node_modules/@emnapi/core": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", + "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==", + "dev": true, + "license": "MIT", + "optional": true, "dependencies": { - "@smithy/types": "^4.13.1", - "fast-xml-parser": "5.4.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" } }, - "node_modules/@aws/lambda-invoke-store": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", - "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=18.0.0" + "node_modules/@emnapi/runtime": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", + "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" } }, - "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" + "tslib": "^2.4.0" } }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=6.0.0" + "node": ">=18" } }, - "node_modules/@babel/runtime": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", - "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@bcoe/v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", - "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { "node": ">=18" } }, - "node_modules/@borewit/text-codec": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", - "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@buape/carbon": { - "version": "0.0.0-beta-20260216184201", - "resolved": "https://registry.npmjs.org/@buape/carbon/-/carbon-0.0.0-beta-20260216184201.tgz", - "integrity": "sha512-u5mgYcigfPVqT7D9gVTGd+3YSflTreQmrWog7ORbb0z5w9eT8ft4rJOdw9fGwr75zMu9kXpSBaAcY2eZoJFSdA==", + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@types/node": "^25.0.9", - "discord-api-types": "0.38.37" - }, - "optionalDependencies": { - "@cloudflare/workers-types": "4.20260120.0", - "@discordjs/voice": "0.19.0", - "@hono/node-server": "1.19.9", - "@types/bun": "1.3.9", - "@types/ws": "8.18.1", - "ws": "8.19.0" - } - }, - "node_modules/@buape/carbon/node_modules/@discordjs/voice": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/@discordjs/voice/-/voice-0.19.0.tgz", - "integrity": "sha512-UyX6rGEXzVyPzb1yvjHtPfTlnLvB5jX/stAMdiytHhfoydX+98hfympdOwsnTktzr+IRvphxTbdErgYDJkEsvw==", - "license": "Apache-2.0", "optional": true, - "dependencies": { - "@types/ws": "^8.18.1", - "discord-api-types": "^0.38.16", - "prism-media": "^1.3.5", - "tslib": "^2.8.1", - "ws": "^8.18.3" - }, + "os": [ + "freebsd" + ], "engines": { - "node": ">=22.12.0" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" + "node": ">=18" } }, - "node_modules/@buape/carbon/node_modules/discord-api-types": { - "version": "0.38.37", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.37.tgz", - "integrity": "sha512-Cv47jzY1jkGkh5sv0bfHYqGgKOWO1peOrGMkDFM4UmaGMOTgOW8QSexhvixa9sVOiz8MnVOBryWYyw/CEVhj7w==", + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, "license": "MIT", - "workspaces": [ - "scripts/actions/documentation" - ] - }, - "node_modules/@buape/carbon/node_modules/prism-media": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/prism-media/-/prism-media-1.3.5.tgz", - "integrity": "sha512-IQdl0Q01m4LrkN1EGIE9lphov5Hy7WWlH6ulf5QdGePLlPas9p2mhgddTEHrlaXYjjFToM1/rWuwF37VF4taaA==", - "license": "Apache-2.0", "optional": true, - "peerDependencies": { - "@discordjs/opus": ">=0.8.0 <1.0.0", - "ffmpeg-static": "^5.0.2 || ^4.2.7 || ^3.0.0 || ^2.4.0", - "node-opus": "^0.3.3", - "opusscript": "^0.0.8" - }, - "peerDependenciesMeta": { - "@discordjs/opus": { - "optional": true - }, - "ffmpeg-static": { - "optional": true - }, - "node-opus": { - "optional": true - }, - "opusscript": { - "optional": true - } + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@cacheable/memory": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.8.tgz", - "integrity": "sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw==", + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@cacheable/utils": "^2.4.0", - "@keyv/bigmap": "^1.3.1", - "hookified": "^1.15.1", - "keyv": "^5.6.0" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@cacheable/node-cache": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@cacheable/node-cache/-/node-cache-1.7.6.tgz", - "integrity": "sha512-6Omk2SgNnjtxB5f/E6bTIWIt5xhdpx39fGNRQgU9lojvRxU68v+qY+SXXLsp3ZGukqoPjsK21wZ6XABFr/Ge3A==", + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, "license": "MIT", - "dependencies": { - "cacheable": "^2.3.1", - "hookified": "^1.14.0", - "keyv": "^5.5.5" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { "node": ">=18" } }, - "node_modules/@cacheable/utils": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.4.0.tgz", - "integrity": "sha512-PeMMsqjVq+bF0WBsxFBxr/WozBJiZKY0rUojuaCoIaKnEl3Ju1wfEwS+SV1DU/cSe8fqHIPiYJFif8T3MVt4cQ==", + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "hashery": "^1.5.0", - "keyv": "^5.6.0" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@clack/core": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.1.0.tgz", - "integrity": "sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA==", + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, "license": "MIT", - "dependencies": { - "sisteransi": "^1.0.5" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@clack/prompts": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.1.0.tgz", - "integrity": "sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g==", + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@clack/core": "1.1.0", - "sisteransi": "^1.0.5" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@cloudflare/workers-types": { - "version": "4.20260120.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260120.0.tgz", - "integrity": "sha512-B8pueG+a5S+mdK3z8oKu1ShcxloZ7qWb68IEyLLaepvdryIbNC7JVPcY0bWsjS56UQVKc5fnyRge3yZIwc9bxw==", - "license": "MIT OR Apache-2.0", - "optional": true - }, - "node_modules/@commitlint/cli": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-20.5.0.tgz", - "integrity": "sha512-yNkyN/tuKTJS3wdVfsZ2tXDM4G4Gi7z+jW54Cki8N8tZqwKBltbIvUUrSbT4hz1bhW/h0CdR+5sCSpXD+wMKaQ==", + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@commitlint/format": "^20.5.0", - "@commitlint/lint": "^20.5.0", - "@commitlint/load": "^20.5.0", - "@commitlint/read": "^20.5.0", - "@commitlint/types": "^20.5.0", - "tinyexec": "^1.0.0", - "yargs": "^17.0.0" - }, - "bin": { - "commitlint": "cli.js" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=v18" + "node": ">=18" } }, - "node_modules/@commitlint/config-conventional": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-20.5.0.tgz", - "integrity": "sha512-t3Ni88rFw1XMa4nZHgOKJ8fIAT9M2j5TnKyTqJzsxea7FUetlNdYFus9dz+MhIRZmc16P0PPyEfh6X2d/qw8SA==", + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], "dev": true, "license": "MIT", - "dependencies": { - "@commitlint/types": "^20.5.0", - "conventional-changelog-conventionalcommits": "^9.2.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=v18" + "node": ">=18" } }, - "node_modules/@commitlint/config-validator": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-20.5.0.tgz", - "integrity": "sha512-T/Uh6iJUzyx7j35GmHWdIiGRQB+ouZDk0pwAaYq4SXgB54KZhFdJ0vYmxiW6AMYICTIWuyMxDBl1jK74oFp/Gw==", + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@commitlint/types": "^20.5.0", - "ajv": "^8.11.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=v18" + "node": ">=18" } }, - "node_modules/@commitlint/ensure": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-20.5.0.tgz", - "integrity": "sha512-IpHqAUesBeW1EDDdjzJeaOxU9tnogLAyXLRBn03SHlj1SGENn2JGZqSWGkFvBJkJzfXAuCNtsoYzax+ZPS+puw==", + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@commitlint/types": "^20.5.0", - "lodash.camelcase": "^4.3.0", - "lodash.kebabcase": "^4.1.1", - "lodash.snakecase": "^4.1.1", - "lodash.startcase": "^4.4.0", - "lodash.upperfirst": "^4.3.1" - }, + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": ">=v18" + "node": ">=18" } }, - "node_modules/@commitlint/execute-rule": { - "version": "20.0.0", - "resolved": "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-20.0.0.tgz", - "integrity": "sha512-xyCoOShoPuPL44gVa+5EdZsBVao/pNzpQhkzq3RdtlFdKZtjWcLlUFQHSWBuhk5utKYykeJPSz2i8ABHQA+ZZw==", + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": ">=v18" + "node": ">=18" } }, - "node_modules/@commitlint/format": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-20.5.0.tgz", - "integrity": "sha512-TI9EwFU/qZWSK7a5qyXMpKPPv3qta7FO4tKW+Wt2al7sgMbLWTsAcDpX1cU8k16TRdsiiet9aOw0zpvRXNJu7Q==", + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@commitlint/types": "^20.5.0", - "picocolors": "^1.1.1" - }, + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": ">=v18" + "node": ">=18" } }, - "node_modules/@commitlint/is-ignored": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-20.5.0.tgz", - "integrity": "sha512-JWLarAsurHJhPozbuAH6GbP4p/hdOCoqS9zJMfqwswne+/GPs5V0+rrsfOkP68Y8PSLphwtFXV0EzJ+GTXTTGg==", + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@commitlint/types": "^20.5.0", - "semver": "^7.6.0" - }, + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": ">=v18" + "node": ">=18" } }, - "node_modules/@commitlint/lint": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-20.5.0.tgz", - "integrity": "sha512-jiM3hNUdu04jFBf1VgPdjtIPvbuVfDTBAc6L98AWcoLjF5sYqkulBHBzlVWll4rMF1T5zeQFB6r//a+s+BBKlA==", + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@commitlint/is-ignored": "^20.5.0", - "@commitlint/parse": "^20.5.0", - "@commitlint/rules": "^20.5.0", - "@commitlint/types": "^20.5.0" - }, + "optional": true, + "os": [ + "openharmony" + ], "engines": { - "node": ">=v18" + "node": ">=18" } }, - "node_modules/@commitlint/load": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-20.5.0.tgz", - "integrity": "sha512-sLhhYTL/KxeOTZjjabKDhwidGZan84XKK1+XFkwDYL/4883kIajcz/dZFAhBJmZPtL8+nBx6bnkzA95YxPeDPw==", + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@commitlint/config-validator": "^20.5.0", - "@commitlint/execute-rule": "^20.0.0", - "@commitlint/resolve-extends": "^20.5.0", - "@commitlint/types": "^20.5.0", - "cosmiconfig": "^9.0.1", - "cosmiconfig-typescript-loader": "^6.1.0", - "is-plain-obj": "^4.1.0", - "lodash.mergewith": "^4.6.2", - "picocolors": "^1.1.1" - }, + "optional": true, + "os": [ + "sunos" + ], "engines": { - "node": ">=v18" + "node": ">=18" } }, - "node_modules/@commitlint/message": { - "version": "20.4.3", - "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-20.4.3.tgz", - "integrity": "sha512-6akwCYrzcrFcTYz9GyUaWlhisY4lmQ3KvrnabmhoeAV8nRH4dXJAh4+EUQ3uArtxxKQkvxJS78hNX2EU3USgxQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/parse": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-20.5.0.tgz", - "integrity": "sha512-SeKWHBMk7YOTnnEWUhx+d1a9vHsjjuo6Uo1xRfPNfeY4bdYFasCH1dDpAv13Lyn+dDPOels+jP6D2GRZqzc5fA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@commitlint/types": "^20.5.0", - "conventional-changelog-angular": "^8.2.0", - "conventional-commits-parser": "^6.3.0" - }, - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/read": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-20.5.0.tgz", - "integrity": "sha512-JDEIJ2+GnWpK8QqwfmW7O42h0aycJEWNqcdkJnyzLD11nf9dW2dWLTVEa8Wtlo4IZFGLPATjR5neA5QlOvIH1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@commitlint/top-level": "^20.4.3", - "@commitlint/types": "^20.5.0", - "git-raw-commits": "^5.0.0", - "minimist": "^1.2.8", - "tinyexec": "^1.0.0" - }, - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/resolve-extends": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-20.5.0.tgz", - "integrity": "sha512-3SHPWUW2v0tyspCTcfSsYml0gses92l6TlogwzvM2cbxDgmhSRc+fldDjvGkCXJrjSM87BBaWYTPWwwyASZRrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@commitlint/config-validator": "^20.5.0", - "@commitlint/types": "^20.5.0", - "global-directory": "^4.0.1", - "import-meta-resolve": "^4.0.0", - "lodash.mergewith": "^4.6.2", - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/rules": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-20.5.0.tgz", - "integrity": "sha512-5NdQXQEdnDPT5pK8O39ZA7HohzPRHEsDGU23cyVCNPQy4WegAbAwrQk3nIu7p2sl3dutPk8RZd91yKTrMTnRkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@commitlint/ensure": "^20.5.0", - "@commitlint/message": "^20.4.3", - "@commitlint/to-lines": "^20.0.0", - "@commitlint/types": "^20.5.0" - }, - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/to-lines": { - "version": "20.0.0", - "resolved": "https://registry.npmjs.org/@commitlint/to-lines/-/to-lines-20.0.0.tgz", - "integrity": "sha512-2l9gmwiCRqZNWgV+pX1X7z4yP0b3ex/86UmUFgoRt672Ez6cAM2lOQeHFRUTuE6sPpi8XBCGnd8Kh3bMoyHwJw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/top-level": { - "version": "20.4.3", - "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-20.4.3.tgz", - "integrity": "sha512-qD9xfP6dFg5jQ3NMrOhG0/w5y3bBUsVGyJvXxdWEwBm8hyx4WOk3kKXw28T5czBYvyeCVJgJJ6aoJZUWDpaacQ==", + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "escalade": "^3.2.0" - }, + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=v18" + "node": ">=18" } }, - "node_modules/@commitlint/types": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-20.5.0.tgz", - "integrity": "sha512-ZJoS8oSq2CAZEpc/YI9SulLrdiIyXeHb/OGqGrkUP6Q7YV+0ouNAa7GjqRdXeQPncHQIDz/jbCTlHScvYvO/gA==", + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], "dev": true, "license": "MIT", - "dependencies": { - "conventional-commits-parser": "^6.3.0", - "picocolors": "^1.1.1" - }, + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=v18" + "node": ">=18" } }, - "node_modules/@conventional-changelog/git-client": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@conventional-changelog/git-client/-/git-client-2.6.0.tgz", - "integrity": "sha512-T+uPDciKf0/ioNNDpMGc8FDsehJClZP0yR3Q5MN6wE/Y/1QZ7F+80OgznnTCOlMEG4AV0LvH2UJi3C/nBnaBUg==", + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@simple-libs/child-process-utils": "^1.0.0", - "@simple-libs/stream-utils": "^1.2.0", - "semver": "^7.5.2" - }, + "optional": true, + "os": [ + "win32" + ], "engines": { "node": ">=18" - }, - "peerDependencies": { - "conventional-commits-filter": "^5.0.0", - "conventional-commits-parser": "^6.3.0" - }, - "peerDependenciesMeta": { - "conventional-commits-filter": { - "optional": true - }, - "conventional-commits-parser": { - "optional": true - } - } - }, - "node_modules/@discordjs/voice": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@discordjs/voice/-/voice-0.19.1.tgz", - "integrity": "sha512-XYbFVyUBB7zhRvrjREfiWDwio24nEp/vFaVe6u9aBIC5UYuT7HvoMt8LgNfZ5hOyaCW0flFr72pkhUGz+gWw4Q==", - "license": "Apache-2.0", - "dependencies": { - "@snazzah/davey": "^0.1.9", - "@types/ws": "^8.18.1", - "discord-api-types": "^0.38.41", - "prism-media": "^1.3.5", - "tslib": "^2.8.1", - "ws": "^8.19.0" - }, - "engines": { - "node": ">=22.12.0" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/@discordjs/voice/node_modules/prism-media": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/prism-media/-/prism-media-1.3.5.tgz", - "integrity": "sha512-IQdl0Q01m4LrkN1EGIE9lphov5Hy7WWlH6ulf5QdGePLlPas9p2mhgddTEHrlaXYjjFToM1/rWuwF37VF4taaA==", - "license": "Apache-2.0", - "peerDependencies": { - "@discordjs/opus": ">=0.8.0 <1.0.0", - "ffmpeg-static": "^5.0.2 || ^4.2.7 || ^3.0.0 || ^2.4.0", - "node-opus": "^0.3.3", - "opusscript": "^0.0.8" - }, - "peerDependenciesMeta": { - "@discordjs/opus": { - "optional": true - }, - "ffmpeg-static": { - "optional": true - }, - "node-opus": { - "optional": true - }, - "opusscript": { - "optional": true - } - } - }, - "node_modules/@emnapi/core": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", - "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==", - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.0", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", - "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", - "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" } }, "node_modules/@eslint-community/eslint-utils": { @@ -1522,10269 +998,3057 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@google/genai": { - "version": "1.45.0", - "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.45.0.tgz", - "integrity": "sha512-+sNRWhKiRibVgc4OKi7aBJJ0A7RcoVD8tGG+eFkqxAWRjASDW+ktS9lLwTDnAxZICzCVoeAdu8dYLJVTX60N9w==", + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, "license": "Apache-2.0", - "dependencies": { - "google-auth-library": "^10.3.0", - "p-retry": "^4.6.2", - "protobufjs": "^7.5.4", - "ws": "^8.18.0" - }, "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.25.2" - }, - "peerDependenciesMeta": { - "@modelcontextprotocol/sdk": { - "optional": true - } + "node": ">=18.18.0" } }, - "node_modules/@grammyjs/runner": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@grammyjs/runner/-/runner-2.0.3.tgz", - "integrity": "sha512-nckmTs1dPWfVQteK9cxqxzE+0m1VRvluLWB8UgFzsjg62w3qthPJt0TYtJBEdG7OedvfQq4vnFAyE6iaMkR42A==", - "license": "MIT", + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "abort-controller": "^3.0.0" + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" }, "engines": { - "node": ">=12.20.0 || >=14.13.1" - }, - "peerDependencies": { - "grammy": "^1.13.1" + "node": ">=18.18.0" } }, - "node_modules/@grammyjs/transformer-throttler": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@grammyjs/transformer-throttler/-/transformer-throttler-1.2.1.tgz", - "integrity": "sha512-CpWB0F3rJdUiKsq7826QhQsxbZi4wqfz1ccKX+fr+AOC+o8K7ZvS+wqX0suSu1QCsyUq2MDpNiKhyL2ZOJUS4w==", - "license": "MIT", - "dependencies": { - "bottleneck": "^2.0.0" - }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": "^12.20.0 || >=14.13.1" + "node": ">=12.22" }, - "peerDependencies": { - "grammy": "^1.0.0" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@grammyjs/types": { - "version": "3.25.0", - "resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-3.25.0.tgz", - "integrity": "sha512-iN9i5p+8ZOu9OMxWNcguojQfz4K/PDyMPOnL7PPCON+SoA/F8OKMH3uR7CVUkYfdNe0GCz8QOzAWrnqusQYFOg==", - "license": "MIT" + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } }, - "node_modules/@hapi/boom": { - "version": "9.1.4", - "resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-9.1.4.tgz", - "integrity": "sha512-Ls1oH8jaN1vNsqcaHVYJrKmgMcKsC1wcp8bujvXrHaAqD2iDYq3HoOwsxwo09Cuda5R5nC0o0IxlrlTuvPuzSw==", - "license": "BSD-3-Clause", - "dependencies": { - "@hapi/hoek": "9.x.x" - } - }, - "node_modules/@hapi/hoek": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", - "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@homebridge/ciao": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@homebridge/ciao/-/ciao-1.3.5.tgz", - "integrity": "sha512-f7MAw7YuoEYgJEQ1VyRcLHGuVmCpmXi65GVR8CAtPWPqIZf/HFr4vHzVpOfQMpEQw9Pt5uh07guuLt5HE8ruog==", + "node_modules/@j178/prek": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@j178/prek/-/prek-0.3.6.tgz", + "integrity": "sha512-+INLkoAR2axZ8MXbHMxcGOv6RIgyGGErAFMpsTcHoNtocEIjGAdTVhAz+JpHC3I6yBmIDYG4ztzuOV1Q2mYvFQ==", + "dev": true, + "hasInstallScript": true, "license": "MIT", "dependencies": { - "debug": "^4.4.3", - "fast-deep-equal": "^3.1.3", - "source-map-support": "^0.5.21", - "tslib": "^2.8.1" + "axios": "^1.13.5", + "axios-proxy-builder": "^0.1.2", + "console.table": "^0.10.0", + "detect-libc": "^2.1.2", + "rimraf": "^6.1.3" }, "bin": { - "ciao-bcs": "lib/bonjour-conformance-testing.js" + "prek": "run-prek.js" + }, + "engines": { + "node": ">=14", + "npm": ">=6" } }, - "node_modules/@hono/node-server": { - "version": "1.19.9", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", - "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", - "license": "MIT", - "optional": true, + "node_modules/@j178/prek/node_modules/rimraf": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, "engines": { - "node": ">=18.14.1" + "node": "20 || >=22" }, - "peerDependencies": { - "hono": "^4" + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@huggingface/jinja": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.6.tgz", - "integrity": "sha512-MyMWyLnjqo+KRJYSH7oWNbsOn5onuIvfXYPcc0WOGxU0eHUV7oAYUoQTl2BMdu7ml+ea/bu11UM+EshbeHwtIA==", + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">=18" + "node": ">=6.0.0" } }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } + "license": "MIT" }, - "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", + "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" }, "funding": { "type": "github", - "url": "https://github.com/sponsors/nzakas" + "url": "https://github.com/sponsors/Brooooooklyn" } }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "node_modules/@oxc-project/types": { + "version": "0.120.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.120.0.tgz", + "integrity": "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@img/colour": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", - "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "license": "MIT", - "engines": { - "node": ">=18" + "funding": { + "url": "https://github.com/sponsors/Boshen" } }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz", + "integrity": "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==", "cpu": [ "arm64" ], - "license": "Apache-2.0", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "darwin" + "android" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.10.tgz", + "integrity": "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==", "cpu": [ - "x64" + "arm64" ], - "license": "Apache-2.0", + "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.10.tgz", + "integrity": "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==", "cpu": [ - "arm64" + "x64" ], - "license": "LGPL-3.0-or-later", + "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.10.tgz", + "integrity": "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==", "cpu": [ "x64" ], - "license": "LGPL-3.0-or-later", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "darwin" + "freebsd" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.10.tgz", + "integrity": "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==", "cpu": [ "arm" ], - "license": "LGPL-3.0-or-later", + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==", "cpu": [ "arm64" ], - "license": "LGPL-3.0-or-later", + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.10.tgz", + "integrity": "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==", "cpu": [ - "ppc64" + "arm64" ], - "license": "LGPL-3.0-or-later", + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==", "cpu": [ - "riscv64" + "ppc64" ], - "license": "LGPL-3.0-or-later", + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==", "cpu": [ "s390x" ], - "license": "LGPL-3.0-or-later", + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==", "cpu": [ "x64" ], - "license": "LGPL-3.0-or-later", + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.10.tgz", + "integrity": "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==", "cpu": [ - "arm64" + "x64" ], - "license": "LGPL-3.0-or-later", + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.10.tgz", + "integrity": "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==", "cpu": [ - "x64" + "arm64" ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", - "cpu": [ - "arm" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", - "cpu": [ - "ppc64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", - "cpu": [ - "riscv64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", - "cpu": [ - "s390x" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "linux" + "openharmony" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.10.tgz", + "integrity": "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==", "cpu": [ "wasm32" ], - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "dev": true, + "license": "MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.7.0" + "@napi-rs/wasm-runtime": "^1.1.1" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "node": ">=14.0.0" } }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.10.tgz", + "integrity": "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==", "cpu": [ "arm64" ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", - "cpu": [ - "ia32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", + "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.10.tgz", + "integrity": "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==", "cpu": [ "x64" ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", + "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.10.tgz", + "integrity": "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==", + "dev": true, + "license": "MIT" }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "dev": true, "license": "MIT" }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "node_modules/@simple-libs/child-process-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@simple-libs/child-process-utils/-/child-process-utils-1.0.2.tgz", + "integrity": "sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw==", + "dev": true, "license": "MIT", "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" + "@simple-libs/stream-utils": "^1.2.0" }, "engines": { - "node": ">=12" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://ko-fi.com/dangreen" } }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "node_modules/@simple-libs/stream-utils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz", + "integrity": "sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==", + "dev": true, "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, "engines": { - "node": ">=12" + "node": ">=18" }, "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "url": "https://ko-fi.com/dangreen" } }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "license": "ISC", - "dependencies": { - "minipass": "^7.0.4" - }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@j178/prek": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@j178/prek/-/prek-0.3.6.tgz", - "integrity": "sha512-+INLkoAR2axZ8MXbHMxcGOv6RIgyGGErAFMpsTcHoNtocEIjGAdTVhAz+JpHC3I6yBmIDYG4ztzuOV1Q2mYvFQ==", + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", "dev": true, - "hasInstallScript": true, "license": "MIT", + "optional": true, "dependencies": { - "axios": "^1.13.5", - "axios-proxy-builder": "^0.1.2", - "console.table": "^0.10.0", - "detect-libc": "^2.1.2", - "rimraf": "^6.1.3" - }, - "bin": { - "prek": "run-prek.js" - }, - "engines": { - "node": ">=14", - "npm": ">=6" + "tslib": "^2.4.0" } }, - "node_modules/@j178/prek/node_modules/rimraf": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", - "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "MIT", "dependencies": { - "glob": "^13.0.3", - "package-json-from-dist": "^1.0.1" - }, - "bin": { - "rimraf": "dist/esm/bin.mjs" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } + "license": "MIT" }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", "dev": true, "license": "MIT" }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "undici-types": "~7.18.0" } }, - "node_modules/@keyv/bigmap": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", - "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.0.tgz", + "integrity": "sha512-nDWulKeik2bL2Va/Wl4x7DLuTKAXa906iRFooIRPR+huHkcvp9QDkPQ2RJdmjOFrqOqvNfoSQLF68deE3xC3CQ==", + "dev": true, "license": "MIT", "dependencies": { - "hashery": "^1.4.0", - "hookified": "^1.15.0" + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.0", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.0.3" }, - "engines": { - "node": ">= 18" + "funding": { + "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "keyv": "^5.6.0" + "@vitest/browser": "4.1.0", + "vitest": "4.1.0" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } } }, - "node_modules/@keyv/serialize": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", - "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "node_modules/@vitest/coverage-v8/node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, "license": "MIT" }, - "node_modules/@kwsites/file-exists": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", - "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==", + "node_modules/@vitest/expect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", + "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "debug": "^4.1.1" + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "chai": "^6.2.2", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@kwsites/promise-deferred": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz", - "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==", - "license": "MIT", - "peer": true - }, - "node_modules/@larksuiteoapi/node-sdk": { - "version": "1.59.0", - "resolved": "https://registry.npmjs.org/@larksuiteoapi/node-sdk/-/node-sdk-1.59.0.tgz", - "integrity": "sha512-sBpkruTvZDOxnVtoTbepWKRX0j1Y1ZElQYu0x7+v088sI9pcpbVp6ZzCGn62dhrKPatzNyCJyzYCPXPYQWccrA==", + "node_modules/@vitest/mocker": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", + "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", + "dev": true, "license": "MIT", "dependencies": { - "axios": "~1.13.3", - "lodash.identity": "^3.0.0", - "lodash.merge": "^4.6.2", - "lodash.pickby": "^4.6.0", - "protobufjs": "^7.2.6", - "qs": "^6.14.2", - "ws": "^8.19.0" - } - }, - "node_modules/@line/bot-sdk": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/@line/bot-sdk/-/bot-sdk-10.6.0.tgz", - "integrity": "sha512-4hSpglL/G/cW2JCcohaYz/BS0uOSJNV9IEYdMm0EiPEvDLayoI2hGq2D86uYPQFD2gvgkyhmzdShpWLG3P5r3w==", - "license": "Apache-2.0", - "dependencies": { - "@types/node": "^24.0.0" + "@vitest/spy": "4.1.0", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" }, - "engines": { - "node": ">=20" + "funding": { + "url": "https://opencollective.com/vitest" }, - "optionalDependencies": { - "axios": "^1.7.4" + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } } }, - "node_modules/@line/bot-sdk/node_modules/@types/node": { - "version": "24.12.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", - "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", + "node_modules/@vitest/pretty-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", + "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", + "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.16.0" + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@line/bot-sdk/node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "license": "MIT" - }, - "node_modules/@lydell/node-pty": { - "version": "1.2.0-beta.3", - "resolved": "https://registry.npmjs.org/@lydell/node-pty/-/node-pty-1.2.0-beta.3.tgz", - "integrity": "sha512-ngGAItlRhmJXrhspxt8kX13n1dVFqzETOq0m/+gqSkO8NJBvNMwP7FZckMwps2UFySdr4yxCXNGu/bumg5at6A==", - "license": "MIT", - "optionalDependencies": { - "@lydell/node-pty-darwin-arm64": "1.2.0-beta.3", - "@lydell/node-pty-darwin-x64": "1.2.0-beta.3", - "@lydell/node-pty-linux-arm64": "1.2.0-beta.3", - "@lydell/node-pty-linux-x64": "1.2.0-beta.3", - "@lydell/node-pty-win32-arm64": "1.2.0-beta.3", - "@lydell/node-pty-win32-x64": "1.2.0-beta.3" - } - }, - "node_modules/@lydell/node-pty-darwin-arm64": { - "version": "1.2.0-beta.3", - "resolved": "https://registry.npmjs.org/@lydell/node-pty-darwin-arm64/-/node-pty-darwin-arm64-1.2.0-beta.3.tgz", - "integrity": "sha512-owcv+e1/OSu3bf9ZBdUQqJsQF888KyuSIiPYFNn0fLhgkhm9F3Pvha76Kj5mCPnodf7hh3suDe7upw7GPRXftQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@lydell/node-pty-darwin-x64": { - "version": "1.2.0-beta.3", - "resolved": "https://registry.npmjs.org/@lydell/node-pty-darwin-x64/-/node-pty-darwin-x64-1.2.0-beta.3.tgz", - "integrity": "sha512-k38O+UviWrWdxtqZBBc/D8NJU11Rey8Y2YMwSWNxLv3eXZZdF5IVpbBkI/2RmLsV5nCcciqLPbukxeZnEfPlwA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@lydell/node-pty-linux-arm64": { - "version": "1.2.0-beta.3", - "resolved": "https://registry.npmjs.org/@lydell/node-pty-linux-arm64/-/node-pty-linux-arm64-1.2.0-beta.3.tgz", - "integrity": "sha512-HUwRpGu3O+4sv9DAQFKnyW5LYhyYu2SDUa/bdFO/t4dIFCM4uDJEq47wfRM7+aYtJTi1b3lakN8SlWeuFQqJQQ==", - "cpu": [ - "arm64" - ], + "node_modules/@vitest/runner": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", + "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@vitest/utils": "4.1.0", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } }, - "node_modules/@lydell/node-pty-linux-x64": { - "version": "1.2.0-beta.3", - "resolved": "https://registry.npmjs.org/@lydell/node-pty-linux-x64/-/node-pty-linux-x64-1.2.0-beta.3.tgz", - "integrity": "sha512-+RRY0PoCUeQaCvPR7/UnkGbxulwbFtoTWJfe+o4T1RcNtngrgaI55I9nl8CD8uqhGrB3smKuyvPM5UtwGhASUw==", - "cpu": [ - "x64" - ], + "node_modules/@vitest/snapshot": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", + "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@vitest/pretty-format": "4.1.0", + "@vitest/utils": "4.1.0", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } }, - "node_modules/@lydell/node-pty-win32-arm64": { - "version": "1.2.0-beta.3", - "resolved": "https://registry.npmjs.org/@lydell/node-pty-win32-arm64/-/node-pty-win32-arm64-1.2.0-beta.3.tgz", - "integrity": "sha512-UEDd9ASp2M3iIYpIzfmfBlpyn4+K1G4CAjYcHWStptCkefoSVXWTiUBIa1KjBjZi3/xmsHIDpBEYTkGWuvLt2Q==", - "cpu": [ - "arm64" - ], + "node_modules/@vitest/spy": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", + "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "funding": { + "url": "https://opencollective.com/vitest" + } }, - "node_modules/@lydell/node-pty-win32-x64": { - "version": "1.2.0-beta.3", - "resolved": "https://registry.npmjs.org/@lydell/node-pty-win32-x64/-/node-pty-win32-x64-1.2.0-beta.3.tgz", - "integrity": "sha512-TpdqSFYx7/Rj+68tuP6F/lkRYrHCYAIJgaS1bx3SctTkb5QAQCFwOKHd4xlsivmEOMT2LdhkJggPxwX9PAO5pQ==", - "cpu": [ - "x64" - ], + "node_modules/@vitest/utils": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", + "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@vitest/pretty-format": "4.1.0", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } }, - "node_modules/@mariozechner/clipboard": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.2.tgz", - "integrity": "sha512-IHQpksNjo7EAtGuHFU+tbWDp5LarH3HU/8WiB9O70ZEoBPHOg0/6afwSLK0QyNMMmx4Bpi/zl6+DcBXe95nWYA==", + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, "license": "MIT", - "optional": true, - "engines": { - "node": ">= 10" + "bin": { + "acorn": "bin/acorn" }, - "optionalDependencies": { - "@mariozechner/clipboard-darwin-arm64": "0.3.2", - "@mariozechner/clipboard-darwin-universal": "0.3.2", - "@mariozechner/clipboard-darwin-x64": "0.3.2", - "@mariozechner/clipboard-linux-arm64-gnu": "0.3.2", - "@mariozechner/clipboard-linux-arm64-musl": "0.3.2", - "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.2", - "@mariozechner/clipboard-linux-x64-gnu": "0.3.2", - "@mariozechner/clipboard-linux-x64-musl": "0.3.2", - "@mariozechner/clipboard-win32-arm64-msvc": "0.3.2", - "@mariozechner/clipboard-win32-x64-msvc": "0.3.2" - } - }, - "node_modules/@mariozechner/clipboard-darwin-arm64": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.2.tgz", - "integrity": "sha512-uBf6K7Je1ihsgvmWxA8UCGCeI+nbRVRXoarZdLjl6slz94Zs1tNKFZqx7aCI5O1i3e0B6ja82zZ06BWrl0MCVw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], "engines": { - "node": ">= 10" + "node": ">=0.4.0" } }, - "node_modules/@mariozechner/clipboard-darwin-universal": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.2.tgz", - "integrity": "sha512-mxSheKTW2U9LsBdXy0SdmdCAE5HqNS9QUmpNHLnfJ+SsbFKALjEZc5oRrVMXxGQSirDvYf5bjmRyT0QYYonnlg==", + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/@mariozechner/clipboard-darwin-x64": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.2.tgz", - "integrity": "sha512-U1BcVEoidvwIp95+HJswSW+xr28EQiHR7rZjH6pn8Sja5yO4Yoe3yCN0Zm8Lo72BbSOK/fTSq0je7CJpaPCspg==", - "cpu": [ - "x64" - ], + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@mariozechner/clipboard-linux-arm64-gnu": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.2.tgz", - "integrity": "sha512-BsinwG3yWTIjdgNCxsFlip7LkfwPk+ruw/aFCXHUg/fb5XC/Ksp+YMQ7u0LUtiKzIv/7LMXgZInJQH6gxbAaqQ==", - "cpu": [ - "arm64" - ], + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-ify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", + "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">= 10" + "node": ">=12" } }, - "node_modules/@mariozechner/clipboard-linux-arm64-musl": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.2.tgz", - "integrity": "sha512-0/Gi5Xq2V6goXBop19ePoHvXsmJD9SzFlO3S+d6+T2b+BlPcpOu3Oa0wTjl+cZrLAAEzA86aPNBI+VVAFDFPKw==", - "cpu": [ - "arm64" - ], + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" } }, - "node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.2.tgz", - "integrity": "sha512-2AFFiXB24qf0zOZsxI1GJGb9wQGlOJyN6UwoXqmKS3dpQi/l6ix30IzDDA4c4ZcCcx4D+9HLYXhC1w7Sov8pXA==", - "cpu": [ - "riscv64" - ], + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" } }, - "node_modules/@mariozechner/clipboard-linux-x64-gnu": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.2.tgz", - "integrity": "sha512-v6fVnsn7WMGg73Dab8QMwyFce7tzGfgEixKgzLP8f1GJqkJZi5zO4k4FOHzSgUufgLil63gnxvMpjWkgfeQN7A==", - "cpu": [ - "x64" - ], + "node_modules/axios-proxy-builder": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/axios-proxy-builder/-/axios-proxy-builder-0.1.2.tgz", + "integrity": "sha512-6uBVsBZzkB3tCC8iyx59mCjQckhB8+GQrI9Cop8eC7ybIsvs/KtnNgEBfRMSEa7GqK2VBGUzgjNYMdPIfotyPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tunnel": "^0.0.6" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">= 10" + "node": "18 || 20 || >=22" } }, - "node_modules/@mariozechner/clipboard-linux-x64-musl": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.2.tgz", - "integrity": "sha512-xVUtnoMQ8v2JVyfJLKKXACA6avdnchdbBkTsZs8BgJQo29qwCp5NIHAUO8gbJ40iaEGToW5RlmVk2M9V0HsHEw==", - "cpu": [ - "x64" - ], + "node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "balanced-match": "^4.0.2" + }, "engines": { - "node": ">= 10" + "node": "18 || 20 || >=22" } }, - "node_modules/@mariozechner/clipboard-win32-arm64-msvc": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.2.tgz", - "integrity": "sha512-AEgg95TNi8TGgak2wSXZkXKCvAUTjWoU1Pqb0ON7JHrX78p616XUFNTJohtIon3e0w6k0pYPZeCuqRCza/Tqeg==", - "cpu": [ - "arm64" - ], + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, "engines": { - "node": ">= 10" + "node": ">= 0.4" } }, - "node_modules/@mariozechner/clipboard-win32-x64-msvc": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.2.tgz", - "integrity": "sha512-tGRuYpZwDOD7HBrCpyRuhGnHHSCknELvqwKKUG4JSfSB7JIU7LKRh6zx6fMUOQd8uISK35TjFg5UcNih+vJhFA==", - "cpu": [ - "x64" - ], + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], "engines": { - "node": ">= 10" + "node": ">=6" } }, - "node_modules/@mariozechner/jiti": { - "version": "2.6.5", - "resolved": "https://registry.npmjs.org/@mariozechner/jiti/-/jiti-2.6.5.tgz", - "integrity": "sha512-faGUlTcXka5l7rv0lP3K3vGW/ejRuOS24RR2aSFWREUQqzjgdsuWNo/IiPqL3kWRGt6Ahl2+qcDAwtdeWeuGUw==", + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", "dependencies": { - "std-env": "^3.10.0", - "yoctocolors": "^2.1.2" + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" }, - "bin": { - "jiti": "lib/jiti-cli.mjs" + "engines": { + "node": ">=12" } }, - "node_modules/@mariozechner/pi-agent-core": { - "version": "0.57.1", - "resolved": "https://registry.npmjs.org/@mariozechner/pi-agent-core/-/pi-agent-core-0.57.1.tgz", - "integrity": "sha512-WXsBbkNWOObFGHkhixaT8GXJpHDd3+fn8QntYF+4R8Sa9WB90ENXWidO6b7vcKX+JX0jjO5dIsQxmzosARJKlg==", + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", - "dependencies": { - "@mariozechner/pi-ai": "^0.57.1" - }, "engines": { - "node": ">=20.0.0" + "node": ">=8" } }, - "node_modules/@mariozechner/pi-ai": { - "version": "0.57.1", - "resolved": "https://registry.npmjs.org/@mariozechner/pi-ai/-/pi-ai-0.57.1.tgz", - "integrity": "sha512-Bd/J4a3YpdzJVyHLih0vDSdB0QPL4ti0XsAwtHOK/8eVhB0fHM1CpcgIrcBFJ23TMcKXMi0qamz18ERfp8tmgg==", + "node_modules/cliui/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, "license": "MIT", - "dependencies": { - "@anthropic-ai/sdk": "^0.73.0", - "@aws-sdk/client-bedrock-runtime": "^3.983.0", - "@google/genai": "^1.40.0", - "@mistralai/mistralai": "1.14.1", - "@sinclair/typebox": "^0.34.41", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "chalk": "^5.6.2", - "openai": "6.26.0", - "partial-json": "^0.1.7", - "proxy-agent": "^6.5.0", - "undici": "^7.19.1", - "zod-to-json-schema": "^3.24.6" - }, - "bin": { - "pi-ai": "dist/cli.js" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@mariozechner/pi-coding-agent": { - "version": "0.57.1", - "resolved": "https://registry.npmjs.org/@mariozechner/pi-coding-agent/-/pi-coding-agent-0.57.1.tgz", - "integrity": "sha512-u5MQEduj68rwVIsRsqrWkJYiJCyPph/a6bMoJAQKo1sb+Pc17Y/ojwa+wGssnUMjEB38AQKofWTVe8NFEpSWNw==", - "license": "MIT", - "dependencies": { - "@mariozechner/jiti": "^2.6.2", - "@mariozechner/pi-agent-core": "^0.57.1", - "@mariozechner/pi-ai": "^0.57.1", - "@mariozechner/pi-tui": "^0.57.1", - "@silvia-odwyer/photon-node": "^0.3.4", - "chalk": "^5.5.0", - "cli-highlight": "^2.1.11", - "diff": "^8.0.2", - "extract-zip": "^2.0.1", - "file-type": "^21.1.1", - "glob": "^13.0.1", - "hosted-git-info": "^9.0.2", - "ignore": "^7.0.5", - "marked": "^15.0.12", - "minimatch": "^10.2.3", - "proper-lockfile": "^4.1.2", - "strip-ansi": "^7.1.0", - "undici": "^7.19.1", - "yaml": "^2.8.2" - }, - "bin": { - "pi": "dist/cli.js" - }, "engines": { - "node": ">=20.6.0" - }, - "optionalDependencies": { - "@mariozechner/clipboard": "^0.3.2" + "node": ">=8" } }, - "node_modules/@mariozechner/pi-tui": { - "version": "0.57.1", - "resolved": "https://registry.npmjs.org/@mariozechner/pi-tui/-/pi-tui-0.57.1.tgz", - "integrity": "sha512-cjoRghLbeAHV0tTJeHgZXaryUi5zzBZofeZ7uJun1gztnckLLRjoVeaPTujNlc5BIfyKvFqhh1QWCZng/MXlpg==", + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", "dependencies": { - "@types/mime-types": "^2.1.4", - "chalk": "^5.5.0", - "get-east-asian-width": "^1.3.0", - "marked": "^15.0.12", - "mime-types": "^3.0.1" + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, "engines": { - "node": ">=20.0.0" - }, - "optionalDependencies": { - "koffi": "^2.9.0" + "node": ">=8" } }, - "node_modules/@mistralai/mistralai": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-1.14.1.tgz", - "integrity": "sha512-IiLmmZFCCTReQgPAT33r7KQ1nYo5JPdvGkrkZqA8qQ2qB1GHgs5LoP5K2ICyrjnpw2n8oSxMM/VP+liiKcGNlQ==", + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", "dependencies": { - "ws": "^8.18.0", - "zod": "^3.25.0 || ^4.0.0", - "zod-to-json-schema": "^3.24.1" - } - }, - "node_modules/@mozilla/readability": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@mozilla/readability/-/readability-0.6.0.tgz", - "integrity": "sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ==", - "license": "Apache-2.0", + "ansi-regex": "^5.0.1" + }, "engines": { - "node": ">=14.0.0" + "node": ">=8" } }, - "node_modules/@napi-rs/canvas": { - "version": "0.1.96", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.96.tgz", - "integrity": "sha512-6NNmNxvoJKeucVjxaaRUt3La2i5jShgiAbaY3G/72s1Vp3U06XPrAIxkAjBxpDcamEn/t+WJ4OOlGmvILo4/Ew==", + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, "license": "MIT", - "workspaces": [ - "e2e/*" - ], + "optional": true, "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "optionalDependencies": { - "@napi-rs/canvas-android-arm64": "0.1.96", - "@napi-rs/canvas-darwin-arm64": "0.1.96", - "@napi-rs/canvas-darwin-x64": "0.1.96", - "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.96", - "@napi-rs/canvas-linux-arm64-gnu": "0.1.96", - "@napi-rs/canvas-linux-arm64-musl": "0.1.96", - "@napi-rs/canvas-linux-riscv64-gnu": "0.1.96", - "@napi-rs/canvas-linux-x64-gnu": "0.1.96", - "@napi-rs/canvas-linux-x64-musl": "0.1.96", - "@napi-rs/canvas-win32-arm64-msvc": "0.1.96", - "@napi-rs/canvas-win32-x64-msvc": "0.1.96" - } - }, - "node_modules/@napi-rs/canvas-android-arm64": { - "version": "0.1.96", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.96.tgz", - "integrity": "sha512-ew1sPrN3dGdZ3L4FoohPfnjq0f9/Jk7o+wP7HkQZokcXgIUD6FIyICEWGhMYzv53j63wUcPvZeAwgewX58/egg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" + "node": ">=0.8" } }, - "node_modules/@napi-rs/canvas-darwin-arm64": { - "version": "0.1.96", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.96.tgz", - "integrity": "sha512-Q/wOXZ5PzTqpdmA5eUOcegCf4Go/zz3aZ5DlzSeDpOjFmfwMKh8EzLAoweQ+mJVagcHQyzoJhaTEnrO68TNyNg==", - "cpu": [ - "arm64" - ], + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" + "dependencies": { + "color-name": "~1.1.4" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" + "engines": { + "node": ">=7.0.0" } }, - "node_modules/@napi-rs/canvas-darwin-x64": { - "version": "0.1.96", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.96.tgz", - "integrity": "sha512-UrXiQz28tQEvGM1qvyptewOAfmUrrd5+wvi6Rzjj2VprZI8iZ2KIvBD2lTTG1bVF95AbeDeG7PJA0D9sLKaOFA==", - "cpu": [ - "x64" - ], + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" + "dependencies": { + "delayed-stream": "~1.0.0" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" + "engines": { + "node": ">= 0.8" } }, - "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { - "version": "0.1.96", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.96.tgz", - "integrity": "sha512-I90ODxweD8aEP6XKU/NU+biso95MwCtQ2F46dUvhec1HesFi0tq/tAJkYic/1aBSiO/1kGKmSeD1B0duOHhEHQ==", - "cpu": [ - "arm" - ], + "node_modules/compare-func": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", + "integrity": "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" + "dependencies": { + "array-ify": "^1.0.0", + "dot-prop": "^5.1.0" } }, - "node_modules/@napi-rs/canvas-linux-arm64-gnu": { - "version": "0.1.96", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.96.tgz", - "integrity": "sha512-Dx/0+RFV++w3PcRy+4xNXkghhXjA5d0Mw1bs95emn5Llinp1vihMaA6WJt3oYv2LAHc36+gnrhIBsPhUyI2SGw==", - "cpu": [ - "arm64" - ], + "node_modules/console.table": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/console.table/-/console.table-0.10.0.tgz", + "integrity": "sha512-dPyZofqggxuvSf7WXvNjuRfnsOk1YazkVP8FdxH4tcH2c37wc79/Yl6Bhr7Lsu00KMgy2ql/qCMuNu8xctZM8g==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" + "dependencies": { + "easy-table": "1.1.0" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" + "engines": { + "node": "> 0.10" } }, - "node_modules/@napi-rs/canvas-linux-arm64-musl": { - "version": "0.1.96", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.96.tgz", - "integrity": "sha512-UvOi7fii3IE2KDfEfhh8m+LpzSRvhGK7o1eho99M2M0HTik11k3GX+2qgVx9EtujN3/bhFFS1kSO3+vPMaJ0Mg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" + "node_modules/conventional-changelog-angular": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.3.0.tgz", + "integrity": "sha512-DOuBwYSqWzfwuRByY9O4oOIvDlkUCTDzfbOgcSbkY+imXXj+4tmrEFao3K+FxemClYfYnZzsvudbwrhje9VHDA==", + "dev": true, + "license": "ISC", + "dependencies": { + "compare-func": "^2.0.0" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" + "engines": { + "node": ">=18" } }, - "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { - "version": "0.1.96", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.96.tgz", - "integrity": "sha512-MBSukhGCQ5nRtf9NbFYWOU080yqkZU1PbuH4o1ROvB4CbPl12fchDR35tU83Wz8gWIM9JTn99lBn9DenPIv7Ig==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" + "node_modules/conventional-changelog-conventionalcommits": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-9.3.0.tgz", + "integrity": "sha512-kYFx6gAyjSIMwNtASkI3ZE99U1fuVDJr0yTYgVy+I2QG46zNZfl2her+0+eoviG82c5WQvW1jMt1eOQTeJLodA==", + "dev": true, + "license": "ISC", + "dependencies": { + "compare-func": "^2.0.0" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" + "engines": { + "node": ">=18" } }, - "node_modules/@napi-rs/canvas-linux-x64-gnu": { - "version": "0.1.96", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.96.tgz", - "integrity": "sha512-I/ccu2SstyKiV3HIeVzyBIWfrJo8cN7+MSQZPnabewWV6hfJ2nY7Df2WqOHmobBRUw84uGR6zfQHsUEio/m5Vg==", - "cpu": [ - "x64" - ], + "node_modules/conventional-commits-parser": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.3.0.tgz", + "integrity": "sha512-RfOq/Cqy9xV9bOA8N+ZH6DlrDR+5S3Mi0B5kACEjESpE+AviIpAptx9a9cFpWCCvgRtWT+0BbUw+e1BZfts9jg==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" + "dependencies": { + "@simple-libs/stream-utils": "^1.2.0", + "meow": "^13.0.0" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" + "bin": { + "conventional-commits-parser": "dist/cli/index.js" + }, + "engines": { + "node": ">=18" } }, - "node_modules/@napi-rs/canvas-linux-x64-musl": { - "version": "0.1.96", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.96.tgz", - "integrity": "sha512-H3uov7qnTl73GDT4h52lAqpJPsl1tIUyNPWJyhQ6gHakohNqqRq3uf80+NEpzcytKGEOENP1wX3yGwZxhjiWEQ==", - "cpu": [ - "x64" - ], + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", + "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, "engines": { - "node": ">= 10" + "node": ">=14" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, - "node_modules/@napi-rs/canvas-win32-arm64-msvc": { - "version": "0.1.96", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.96.tgz", - "integrity": "sha512-ATp6Y+djOjYtkfV/VRH7CZ8I1MEtkUQBmKUbuWw5zWEHHqfL0cEcInE4Cxgx7zkNAhEdBbnH8HMVrqNp+/gwxA==", - "cpu": [ - "arm64" - ], + "node_modules/cosmiconfig-typescript-loader": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-6.2.0.tgz", + "integrity": "sha512-GEN39v7TgdxgIoNcdkRE3uiAzQt3UXLyHbRHD6YoL048XAeOomyxaP+Hh/+2C6C2wYjxJ2onhJcsQp+L4YEkVQ==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "jiti": "^2.6.1" + }, "engines": { - "node": ">= 10" + "node": ">=v18" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" + "peerDependencies": { + "@types/node": "*", + "cosmiconfig": ">=9", + "typescript": ">=5" } }, - "node_modules/@napi-rs/canvas-win32-x64-msvc": { - "version": "0.1.96", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.96.tgz", - "integrity": "sha512-UYGdTltVd+Z8mcIuoqGmAXXUvwH5CLf2M6mIB5B0/JmX5J041jETjqtSYl7gN+aj3k1by/SG6sS0hAwCqyK7zw==", - "cpu": [ - "x64" - ], + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" + "engines": { + "node": ">= 8" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", - "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", - "license": "MIT", - "optional": true, + "node_modules/cross-spawn/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", - "@tybys/wasm-util": "^0.10.1" + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@node-llama-cpp/linux-arm64": { - "version": "3.16.2", - "resolved": "https://registry.npmjs.org/@node-llama-cpp/linux-arm64/-/linux-arm64-3.16.2.tgz", - "integrity": "sha512-CxzgPsS84wL3W5sZRgxP3c9iJKEW+USrak1SmX6EAJxW/v9QGzehvT6W/aR1FyfidiIyQtOp3ga0Gg/9xfJPGw==", - "cpu": [ - "arm64", - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, "engines": { - "node": ">=20.0.0" + "node": ">= 8" } }, - "node_modules/@node-llama-cpp/linux-armv7l": { - "version": "3.16.2", - "resolved": "https://registry.npmjs.org/@node-llama-cpp/linux-armv7l/-/linux-armv7l-3.16.2.tgz", - "integrity": "sha512-9G6W/MkQ/DLwGmpcj143NQ50QJg5gQZfzVf5RYx77VczBqhgwkgYHILekYrOs4xanOeqeJ8jnOnQQSp1YaJZUg==", - "cpu": [ - "arm", - "x64" - ], + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, + "dependencies": { + "ms": "^2.1.3" + }, "engines": { - "node": ">=20.0.0" + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/@node-llama-cpp/linux-x64": { - "version": "3.16.2", - "resolved": "https://registry.npmjs.org/@node-llama-cpp/linux-x64/-/linux-x64-3.16.2.tgz", - "integrity": "sha512-OXYf8rVfoDyvN+YrfKk8F9An9a5GOxVIM8OcR1U911tc0oRNf8yfJrQ8KrM75R26gwq0Y6YZwVTP0vRCInwWOw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=20.0.0" - } + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" }, - "node_modules/@node-llama-cpp/linux-x64-cuda": { - "version": "3.16.2", - "resolved": "https://registry.npmjs.org/@node-llama-cpp/linux-x64-cuda/-/linux-x64-cuda-3.16.2.tgz", - "integrity": "sha512-LTBQFqjin7tyrLNJz0XWTB5QAHDsZV71/qiiRRjXdBKSZHVVaPLfdgxypGu7ggPeBNsv+MckRXdlH5C7yMtE4A==", - "cpu": [ - "x64" - ], + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=20.0.0" + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@node-llama-cpp/linux-x64-cuda-ext": { - "version": "3.16.2", - "resolved": "https://registry.npmjs.org/@node-llama-cpp/linux-x64-cuda-ext/-/linux-x64-cuda-ext-3.16.2.tgz", - "integrity": "sha512-47d9myCJauZyzAlN7IK1eIt/4CcBMslF+yHy4q+yJotD/RV/S6qRpK2kGn+ybtdVjkPGNCoPkHKcyla9iIVjbw==", - "cpu": [ - "x64" - ], + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, "engines": { - "node": ">=20.0.0" + "node": ">=0.4.0" } }, - "node_modules/@node-llama-cpp/linux-x64-vulkan": { - "version": "3.16.2", - "resolved": "https://registry.npmjs.org/@node-llama-cpp/linux-x64-vulkan/-/linux-x64-vulkan-3.16.2.tgz", - "integrity": "sha512-HDLAw4ZhwJuhKuF6n4x520yZXAQZahUOXtCGvPubjfpmIOElKrfDvCVlRsthAP0JwcwINzIQlVys3boMIXfBgw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=20.0.0" + "node": ">=8" } }, - "node_modules/@node-llama-cpp/mac-arm64-metal": { - "version": "3.16.2", - "resolved": "https://registry.npmjs.org/@node-llama-cpp/mac-arm64-metal/-/mac-arm64-metal-3.16.2.tgz", - "integrity": "sha512-nEZ74qB0lUohF88yR741YUrUqz/qD+FJFzUTHj0FwxAynSZCjvwtzEDtavRlh3qd3yLD/0ChNn00/RQ54ISImw==", - "cpu": [ - "arm64", - "x64" - ], + "node_modules/dot-prop": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", + "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, + "dependencies": { + "is-obj": "^2.0.0" + }, "engines": { - "node": ">=20.0.0" + "node": ">=8" } }, - "node_modules/@node-llama-cpp/mac-x64": { - "version": "3.16.2", - "resolved": "https://registry.npmjs.org/@node-llama-cpp/mac-x64/-/mac-x64-3.16.2.tgz", - "integrity": "sha512-BjA+DgeDt+kRxVMV6kChb9XVXm7U5b90jUif7Z/s6ZXtOOnV6exrTM2W09kbSqAiNhZmctcVY83h2dwNTZ/yIw==", - "cpu": [ - "x64" - ], + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, "engines": { - "node": ">=20.0.0" + "node": ">= 0.4" } }, - "node_modules/@node-llama-cpp/win-arm64": { - "version": "3.16.2", - "resolved": "https://registry.npmjs.org/@node-llama-cpp/win-arm64/-/win-arm64-3.16.2.tgz", - "integrity": "sha512-XHNFQzUjYODtkZjIn4NbQVrBtGB9RI9TpisiALryqfrIqagQmjBh6dmxZWlt5uduKAfT7M2/2vrABGR490FACA==", - "cpu": [ - "arm64", - "x64" - ], + "node_modules/easy-table": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/easy-table/-/easy-table-1.1.0.tgz", + "integrity": "sha512-oq33hWOSSnl2Hoh00tZWaIPi1ievrD9aFG82/IgjlycAnW9hHx5PkJiXpxPsgEE+H7BsbVQXFVFST8TEXS6/pA==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=20.0.0" + "optionalDependencies": { + "wcwidth": ">=1.0.1" } }, - "node_modules/@node-llama-cpp/win-x64": { - "version": "3.16.2", - "resolved": "https://registry.npmjs.org/@node-llama-cpp/win-x64/-/win-x64-3.16.2.tgz", - "integrity": "sha512-etrivzbyLNVhZlUosFW8JSL0OSiuKQf9qcI3dNdehD907sHquQbBJrG7lXcdL6IecvXySp3oAwCkM87VJ0b3Fg==", - "cpu": [ - "x64" - ], + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, "engines": { - "node": ">=20.0.0" + "node": ">=6" } }, - "node_modules/@node-llama-cpp/win-x64-cuda": { - "version": "3.16.2", - "resolved": "https://registry.npmjs.org/@node-llama-cpp/win-x64-cuda/-/win-x64-cuda-3.16.2.tgz", - "integrity": "sha512-jStDELHrU3rKQMOk5Hs5bWEazyjE2hzHwpNf6SblOpaGkajM/HJtxEZoL0mLHJx5qeXs4oOVkr7AzuLy0WPpNA==", - "cpu": [ - "x64" - ], + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=20.0.0" + "dependencies": { + "is-arrayish": "^0.2.1" } }, - "node_modules/@node-llama-cpp/win-x64-cuda-ext": { - "version": "3.16.2", - "resolved": "https://registry.npmjs.org/@node-llama-cpp/win-x64-cuda-ext/-/win-x64-cuda-ext-3.16.2.tgz", - "integrity": "sha512-sdv4Kzn9bOQWNBRvw6B/zcn8dQRfZhjIHv5AfDBIOfRlSCgjebFpBeYUoU4wZPpjr3ISwcqO5MEWsw+AbUdV3Q==", - "cpu": [ - "x64" - ], + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, "engines": { - "node": ">=20.0.0" + "node": ">= 0.4" } }, - "node_modules/@node-llama-cpp/win-x64-vulkan": { - "version": "3.16.2", - "resolved": "https://registry.npmjs.org/@node-llama-cpp/win-x64-vulkan/-/win-x64-vulkan-3.16.2.tgz", - "integrity": "sha512-9xuHFCOhCQjZgQSFrk79EuSKn9nGWt/SAq/3wujQSQLtgp8jGdtZgwcmuDUoemInf10en2dcOmEt7t8dQdC3XA==", - "cpu": [ - "x64" - ], + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, "engines": { - "node": ">=20.0.0" + "node": ">= 0.4" } }, - "node_modules/@octokit/app": { - "version": "16.1.2", - "resolved": "https://registry.npmjs.org/@octokit/app/-/app-16.1.2.tgz", - "integrity": "sha512-8j7sEpUYVj18dxvh0KWj6W/l6uAiVRBl1JBDVRqH1VHKAO/G5eRVl4yEoYACjakWers1DjUkcCHyJNQK47JqyQ==", + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@octokit/auth-app": "^8.1.2", - "@octokit/auth-unauthenticated": "^7.0.3", - "@octokit/core": "^7.0.6", - "@octokit/oauth-app": "^8.0.3", - "@octokit/plugin-paginate-rest": "^14.0.0", - "@octokit/types": "^16.0.0", - "@octokit/webhooks": "^14.0.0" + "es-errors": "^1.3.0" }, "engines": { - "node": ">= 20" + "node": ">= 0.4" } }, - "node_modules/@octokit/auth-app": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/@octokit/auth-app/-/auth-app-8.2.0.tgz", - "integrity": "sha512-vVjdtQQwomrZ4V46B9LaCsxsySxGoHsyw6IYBov/TqJVROrlYdyNgw5q6tQbB7KZt53v1l1W53RiqTvpzL907g==", + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@octokit/auth-oauth-app": "^9.0.3", - "@octokit/auth-oauth-user": "^6.0.2", - "@octokit/request": "^10.0.6", - "@octokit/request-error": "^7.0.2", - "@octokit/types": "^16.0.0", - "toad-cache": "^3.7.0", - "universal-github-app-jwt": "^2.2.0", - "universal-user-agent": "^7.0.0" + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" }, "engines": { - "node": ">= 20" + "node": ">= 0.4" } }, - "node_modules/@octokit/auth-oauth-app": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-app/-/auth-oauth-app-9.0.3.tgz", - "integrity": "sha512-+yoFQquaF8OxJSxTb7rnytBIC2ZLbLqA/yb71I4ZXT9+Slw4TziV9j/kyGhUFRRTF2+7WlnIWsePZCWHs+OGjg==", + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, "license": "MIT", - "peer": true, - "dependencies": { - "@octokit/auth-oauth-device": "^8.0.3", - "@octokit/auth-oauth-user": "^6.0.2", - "@octokit/request": "^10.0.6", - "@octokit/types": "^16.0.0", - "universal-user-agent": "^7.0.0" + "bin": { + "esbuild": "bin/esbuild" }, "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/auth-oauth-device": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-device/-/auth-oauth-device-8.0.3.tgz", - "integrity": "sha512-zh2W0mKKMh/VWZhSqlaCzY7qFyrgd9oTWmTmHaXnHNeQRCZr/CXy2jCgHo4e4dJVTiuxP5dLa0YM5p5QVhJHbw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@octokit/oauth-methods": "^6.0.2", - "@octokit/request": "^10.0.6", - "@octokit/types": "^16.0.0", - "universal-user-agent": "^7.0.0" + "node": ">=18" }, - "engines": { - "node": ">= 20" + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" } }, - "node_modules/@octokit/auth-oauth-user": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-user/-/auth-oauth-user-6.0.2.tgz", - "integrity": "sha512-qLoPPc6E6GJoz3XeDG/pnDhJpTkODTGG4kY0/Py154i/I003O9NazkrwJwRuzgCalhzyIeWQ+6MDvkUmKXjg/A==", + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "@octokit/auth-oauth-device": "^8.0.3", - "@octokit/oauth-methods": "^6.0.2", - "@octokit/request": "^10.0.6", - "@octokit/types": "^16.0.0", - "universal-user-agent": "^7.0.0" - }, "engines": { - "node": ">= 20" + "node": ">=6" } }, - "node_modules/@octokit/auth-token": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", - "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/auth-unauthenticated": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/@octokit/auth-unauthenticated/-/auth-unauthenticated-7.0.3.tgz", - "integrity": "sha512-8Jb1mtUdmBHL7lGmop9mU9ArMRUTRhg8vp0T1VtZ4yd9vEm3zcLwmjQkhNEduKawOOORie61xhtYIhTDN+ZQ3g==", - "license": "MIT", - "peer": true, - "dependencies": { - "@octokit/request-error": "^7.0.2", - "@octokit/types": "^16.0.0" + "node": ">=10" }, - "engines": { - "node": ">= 20" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@octokit/core": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", - "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", + "node_modules/eslint": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.1.0.tgz", + "integrity": "sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@octokit/auth-token": "^6.0.0", - "@octokit/graphql": "^9.0.3", - "@octokit/request": "^10.0.6", - "@octokit/request-error": "^7.0.2", - "@octokit/types": "^16.0.0", - "before-after-hook": "^4.0.0", - "universal-user-agent": "^7.0.0" + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.3", + "@eslint/config-helpers": "^0.5.3", + "@eslint/core": "^1.1.1", + "@eslint/plugin-kit": "^0.6.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/endpoint": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.3.tgz", - "integrity": "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==", - "license": "MIT", - "peer": true, - "dependencies": { - "@octokit/types": "^16.0.0", - "universal-user-agent": "^7.0.2" + "bin": { + "eslint": "bin/eslint.js" }, "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/graphql": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz", - "integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@octokit/request": "^10.0.6", - "@octokit/types": "^16.0.0", - "universal-user-agent": "^7.0.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, - "engines": { - "node": ">= 20" + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } } }, - "node_modules/@octokit/oauth-app": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/@octokit/oauth-app/-/oauth-app-8.0.3.tgz", - "integrity": "sha512-jnAjvTsPepyUaMu9e69hYBuozEPgYqP4Z3UnpmvoIzHDpf8EXDGvTY1l1jK0RsZ194oRd+k6Hm13oRU8EoDFwg==", - "license": "MIT", - "peer": true, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "@octokit/auth-oauth-app": "^9.0.2", - "@octokit/auth-oauth-user": "^6.0.1", - "@octokit/auth-unauthenticated": "^7.0.2", - "@octokit/core": "^7.0.5", - "@octokit/oauth-authorization-url": "^8.0.0", - "@octokit/oauth-methods": "^6.0.1", - "@types/aws-lambda": "^8.10.83", - "universal-user-agent": "^7.0.0" + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" }, "engines": { - "node": ">= 20" + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/@octokit/oauth-authorization-url": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@octokit/oauth-authorization-url/-/oauth-authorization-url-8.0.0.tgz", - "integrity": "sha512-7QoLPRh/ssEA/HuHBHdVdSgF8xNLz/Bc5m9fZkArJE5bb6NmVkDm3anKxXPmN1zh6b5WKZPRr3697xKT/yM3qQ==", - "license": "MIT", - "peer": true, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">= 20" + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/@octokit/oauth-methods": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@octokit/oauth-methods/-/oauth-methods-6.0.2.tgz", - "integrity": "sha512-HiNOO3MqLxlt5Da5bZbLV8Zarnphi4y9XehrbaFMkcoJ+FL7sMxH/UlUsCVxpddVu4qvNDrBdaTVE2o4ITK8ng==", + "node_modules/eslint/node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@octokit/oauth-authorization-url": "^8.0.0", - "@octokit/request": "^10.0.6", - "@octokit/request-error": "^7.0.2", - "@octokit/types": "^16.0.0" + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" }, - "engines": { - "node": ">= 20" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@octokit/openapi-types": { - "version": "27.0.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", - "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", - "license": "MIT", - "peer": true - }, - "node_modules/@octokit/openapi-webhooks-types": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-webhooks-types/-/openapi-webhooks-types-12.1.0.tgz", - "integrity": "sha512-WiuzhOsiOvb7W3Pvmhf8d2C6qaLHXrWiLBP4nJ/4kydu+wpagV5Fkz9RfQwV2afYzv3PB+3xYgp4mAdNGjDprA==", - "license": "MIT", - "peer": true - }, - "node_modules/@octokit/plugin-paginate-graphql": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-graphql/-/plugin-paginate-graphql-6.0.0.tgz", - "integrity": "sha512-crfpnIoFiBtRkvPqOyLOsw12XsveYuY2ieP6uYDosoUegBJpSVxGwut9sxUgFFcll3VTOTqpUf8yGd8x1OmAkQ==", + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">= 20" - }, - "peerDependencies": { - "@octokit/core": ">=6" + "node": ">= 4" } }, - "node_modules/@octokit/plugin-paginate-rest": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz", - "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==", - "license": "MIT", - "peer": true, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "@octokit/types": "^16.0.0" + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" }, "engines": { - "node": ">= 20" + "node": "^20.19.0 || ^22.13.0 || >=24" }, - "peerDependencies": { - "@octokit/core": ">=6" + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/@octokit/plugin-rest-endpoint-methods": { - "version": "17.0.0", - "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-17.0.0.tgz", - "integrity": "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==", - "license": "MIT", - "peer": true, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "@octokit/types": "^16.0.0" + "estraverse": "^5.1.0" }, "engines": { - "node": ">= 20" - }, - "peerDependencies": { - "@octokit/core": ">=6" + "node": ">=0.10" } }, - "node_modules/@octokit/plugin-retry": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@octokit/plugin-retry/-/plugin-retry-8.1.0.tgz", - "integrity": "sha512-O1FZgXeiGb2sowEr/hYTr6YunGdSAFWnr2fyW39Ah85H8O33ELASQxcvOFF5LE6Tjekcyu2ms4qAzJVhSaJxTw==", - "license": "MIT", - "peer": true, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "@octokit/request-error": "^7.0.2", - "@octokit/types": "^16.0.0", - "bottleneck": "^2.15.3" + "estraverse": "^5.2.0" }, "engines": { - "node": ">= 20" - }, - "peerDependencies": { - "@octokit/core": ">=7" + "node": ">=4.0" } }, - "node_modules/@octokit/plugin-throttling": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@octokit/plugin-throttling/-/plugin-throttling-11.0.3.tgz", - "integrity": "sha512-34eE0RkFCKycLl2D2kq7W+LovheM/ex3AwZCYN8udpi6bxsyjZidb2McXs69hZhLmJlDqTSP8cH+jSRpiaijBg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@octokit/types": "^16.0.0", - "bottleneck": "^2.15.3" - }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", "engines": { - "node": ">= 20" - }, - "peerDependencies": { - "@octokit/core": "^7.0.0" + "node": ">=4.0" } }, - "node_modules/@octokit/request": { - "version": "10.0.8", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.8.tgz", - "integrity": "sha512-SJZNwY9pur9Agf7l87ywFi14W+Hd9Jg6Ifivsd33+/bGUQIjNujdFiXII2/qSlN2ybqUHfp5xpekMEjIBTjlSw==", + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@octokit/endpoint": "^11.0.3", - "@octokit/request-error": "^7.0.2", - "@octokit/types": "^16.0.0", - "fast-content-type-parse": "^3.0.0", - "json-with-bigint": "^3.5.3", - "universal-user-agent": "^7.0.2" - }, - "engines": { - "node": ">= 20" + "@types/estree": "^1.0.0" } }, - "node_modules/@octokit/request-error": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz", - "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@octokit/types": "^16.0.0" - }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", "engines": { - "node": ">= 20" + "node": ">=0.10.0" } }, - "node_modules/@octokit/types": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", - "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@octokit/openapi-types": "^27.0.0" + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/@octokit/webhooks": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/@octokit/webhooks/-/webhooks-14.2.0.tgz", - "integrity": "sha512-da6KbdNCV5sr1/txD896V+6W0iamFWrvVl8cHkBSPT+YlvmT3DwXa4jxZnQc+gnuTEqSWbBeoSZYTayXH9wXcw==", + "node_modules/execa/node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@octokit/openapi-webhooks-types": "12.1.0", - "@octokit/request-error": "^7.0.0", - "@octokit/webhooks-methods": "^6.0.0" + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" }, "engines": { - "node": ">= 20" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@octokit/webhooks-methods": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@octokit/webhooks-methods/-/webhooks-methods-6.0.0.tgz", - "integrity": "sha512-MFlzzoDJVw/GcbfzVC1RLR36QqkTLUf79vLVO3D+xn7r0QgxnFoLZgtrzxiQErAjFUOdH6fas2KeQJ1yr/qaXQ==", + "node_modules/execa/node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">= 20" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@oxc-project/types": { - "version": "0.120.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.120.0.tgz", - "integrity": "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==", + "node_modules/execa/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "dev": true, - "license": "MIT", + "license": "ISC", + "engines": { + "node": ">=14" + }, "funding": { - "url": "https://github.com/sponsors/Boshen" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@pinojs/redact": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", - "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", - "license": "MIT" - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "license": "MIT", - "optional": true, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=14" + "node": ">=12.0.0" } }, - "node_modules/@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "license": "BSD-3-Clause" + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" }, - "node_modules/@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "license": "BSD-3-Clause" + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" }, - "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", - "license": "BSD-3-Clause" + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" }, - "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "BSD-3-Clause" }, - "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, - "node_modules/@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", - "license": "BSD-3-Clause" - }, - "node_modules/@reflink/reflink": { - "version": "0.1.19", - "resolved": "https://registry.npmjs.org/@reflink/reflink/-/reflink-0.1.19.tgz", - "integrity": "sha512-DmCG8GzysnCZ15bres3N5AHCmwBwYgp0As6xjhQ47rAUTUXxJiK+lLUxaGsX3hd/30qUpVElh05PbGuxRPgJwA==", + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "dev": true, "license": "MIT", - "optional": true, - "peer": true, + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, "engines": { - "node": ">= 10" + "node": ">=18" }, - "optionalDependencies": { - "@reflink/reflink-darwin-arm64": "0.1.19", - "@reflink/reflink-darwin-x64": "0.1.19", - "@reflink/reflink-linux-arm64-gnu": "0.1.19", - "@reflink/reflink-linux-arm64-musl": "0.1.19", - "@reflink/reflink-linux-x64-gnu": "0.1.19", - "@reflink/reflink-linux-x64-musl": "0.1.19", - "@reflink/reflink-win32-arm64-msvc": "0.1.19", - "@reflink/reflink-win32-x64-msvc": "0.1.19" - } - }, - "node_modules/@reflink/reflink-darwin-arm64": { - "version": "0.1.19", - "resolved": "https://registry.npmjs.org/@reflink/reflink-darwin-arm64/-/reflink-darwin-arm64-0.1.19.tgz", - "integrity": "sha512-ruy44Lpepdk1FqDz38vExBY/PVUsjxZA+chd9wozjUH9JjuDT/HEaQYA6wYN9mf041l0yLVar6BCZuWABJvHSA==", - "cpu": [ - "arm64" - ], + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, + "dependencies": { + "flat-cache": "^4.0.0" + }, "engines": { - "node": ">= 10" + "node": ">=16.0.0" } }, - "node_modules/@reflink/reflink-darwin-x64": { - "version": "0.1.19", - "resolved": "https://registry.npmjs.org/@reflink/reflink-darwin-x64/-/reflink-darwin-x64-0.1.19.tgz", - "integrity": "sha512-By85MSWrMZa+c26TcnAy8SDk0sTUkYlNnwknSchkhHpGXOtjNDUOxJE9oByBnGbeuIE1PiQsxDG3Ud+IVV9yuA==", - "cpu": [ - "x64" - ], + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, "engines": { - "node": ">= 10" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@reflink/reflink-linux-arm64-gnu": { - "version": "0.1.19", - "resolved": "https://registry.npmjs.org/@reflink/reflink-linux-arm64-gnu/-/reflink-linux-arm64-gnu-0.1.19.tgz", - "integrity": "sha512-7P+er8+rP9iNeN+bfmccM4hTAaLP6PQJPKWSA4iSk2bNvo6KU6RyPgYeHxXmzNKzPVRcypZQTpFgstHam6maVg==", - "cpu": [ - "arm64" - ], + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, "engines": { - "node": ">= 10" + "node": ">=16" } }, - "node_modules/@reflink/reflink-linux-arm64-musl": { - "version": "0.1.19", - "resolved": "https://registry.npmjs.org/@reflink/reflink-linux-arm64-musl/-/reflink-linux-arm64-musl-0.1.19.tgz", - "integrity": "sha512-37iO/Dp6m5DDaC2sf3zPtx/hl9FV3Xze4xoYidrxxS9bgP3S8ALroxRK6xBG/1TtfXKTvolvp+IjrUU6ujIGmA==", - "cpu": [ - "arm64" - ], + "node_modules/flat-cache/node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 10" + "dependencies": { + "json-buffer": "3.0.1" } }, - "node_modules/@reflink/reflink-linux-x64-gnu": { - "version": "0.1.19", - "resolved": "https://registry.npmjs.org/@reflink/reflink-linux-x64-gnu/-/reflink-linux-x64-gnu-0.1.19.tgz", - "integrity": "sha512-jbI8jvuYCaA3MVUdu8vLoLAFqC+iNMpiSuLbxlAgg7x3K5bsS8nOpTRnkLF7vISJ+rVR8W+7ThXlXlUQ93ulkw==", - "cpu": [ - "x64" + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } ], "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, "engines": { - "node": ">= 10" + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } } }, - "node_modules/@reflink/reflink-linux-x64-musl": { - "version": "0.1.19", - "resolved": "https://registry.npmjs.org/@reflink/reflink-linux-x64-musl/-/reflink-linux-x64-musl-0.1.19.tgz", - "integrity": "sha512-e9FBWDe+lv7QKAwtKOt6A2W/fyy/aEEfr0g6j/hWzvQcrzHCsz07BNQYlNOjTfeytrtLU7k449H1PI95jA4OjQ==", - "cpu": [ - "x64" - ], + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, "engines": { - "node": ">= 10" + "node": ">= 6" } }, - "node_modules/@reflink/reflink-win32-arm64-msvc": { - "version": "0.1.19", - "resolved": "https://registry.npmjs.org/@reflink/reflink-win32-arm64-msvc/-/reflink-win32-arm64-msvc-0.1.19.tgz", - "integrity": "sha512-09PxnVIQcd+UOn4WAW73WU6PXL7DwGS6wPlkMhMg2zlHHG65F3vHepOw06HFCq+N42qkaNAc8AKIabWvtk6cIQ==", - "cpu": [ - "arm64" - ], + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, "engines": { - "node": ">= 10" + "node": ">= 0.6" } }, - "node_modules/@reflink/reflink-win32-x64-msvc": { - "version": "0.1.19", - "resolved": "https://registry.npmjs.org/@reflink/reflink-win32-x64-msvc/-/reflink-win32-x64-msvc-0.1.19.tgz", - "integrity": "sha512-E//yT4ni2SyhwP8JRjVGWr3cbnhWDiPLgnQ66qqaanjjnMiu3O/2tjCPQXlcGc/DEYofpDc9fvhv6tALQsMV9w==", - "cpu": [ - "x64" - ], + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, + "dependencies": { + "mime-db": "1.52.0" + }, "engines": { - "node": ">= 10" + "node": ">= 0.6" } }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz", - "integrity": "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==", - "cpu": [ - "arm64" - ], + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, + "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ - "android" + "darwin" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.10.tgz", - "integrity": "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==", - "cpu": [ - "arm64" - ], + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.10.tgz", - "integrity": "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==", - "cpu": [ - "x64" - ], + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "license": "ISC", "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.10.tgz", - "integrity": "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==", - "cpu": [ - "x64" - ], + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.10.tgz", - "integrity": "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==", - "cpu": [ - "arm" - ], + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">= 0.4" } }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.10.tgz", - "integrity": "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==", - "cpu": [ - "arm64" - ], + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.10.tgz", - "integrity": "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==", - "cpu": [ - "arm64" - ], + "node_modules/git-raw-commits": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-5.0.1.tgz", + "integrity": "sha512-Y+csSm2GD/PCSh6Isd/WiMjNAydu0VBiG9J7EdQsNA5P9uXvLayqjmTsNlK5Gs9IhblFZqOU0yid5Il5JPoLiQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@conventional-changelog/git-client": "^2.6.0", + "meow": "^13.0.0" + }, + "bin": { + "git-raw-commits": "src/cli.js" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.10.tgz", - "integrity": "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==", - "cpu": [ - "ppc64" - ], + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.10.tgz", - "integrity": "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==", - "cpu": [ - "s390x" - ], + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=10.13.0" } }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.10.tgz", - "integrity": "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==", - "cpu": [ - "x64" - ], + "node_modules/global-directory": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", + "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "ini": "4.1.1" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.10.tgz", - "integrity": "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==", - "cpu": [ - "x64" - ], + "node_modules/global-directory/node_modules/ini": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "license": "ISC", "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.10.tgz", - "integrity": "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==", - "cpu": [ - "arm64" - ], + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.10.tgz", - "integrity": "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==", - "cpu": [ - "wasm32" - ], + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" - }, "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.10.tgz", - "integrity": "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=8" } }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.10.tgz", - "integrity": "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==", - "cpu": [ - "x64" - ], + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.10.tgz", - "integrity": "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@silvia-odwyer/photon-node": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", - "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", - "license": "Apache-2.0" - }, - "node_modules/@simple-libs/child-process-utils": { + "node_modules/has-tostringtag": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@simple-libs/child-process-utils/-/child-process-utils-1.0.2.tgz", - "integrity": "sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw==", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "dev": true, "license": "MIT", "dependencies": { - "@simple-libs/stream-utils": "^1.2.0" + "has-symbols": "^1.0.3" }, "engines": { - "node": ">=18" + "node": ">= 0.4" }, "funding": { - "url": "https://ko-fi.com/dangreen" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/@simple-libs/stream-utils": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz", - "integrity": "sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==", + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://ko-fi.com/dangreen" - } - }, - "node_modules/@sinclair/typebox": { - "version": "0.34.48", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", - "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", - "license": "MIT" - }, - "node_modules/@slack/bolt": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@slack/bolt/-/bolt-4.6.0.tgz", - "integrity": "sha512-xPgfUs2+OXSugz54Ky07pA890+Qydk22SYToi8uGpXeHSt1JWwFJkRyd/9Vlg5I1AdfdpGXExDpwnbuN9Q/2dQ==", - "license": "MIT", "dependencies": { - "@slack/logger": "^4.0.0", - "@slack/oauth": "^3.0.4", - "@slack/socket-mode": "^2.0.5", - "@slack/types": "^2.18.0", - "@slack/web-api": "^7.12.0", - "axios": "^1.12.0", - "express": "^5.0.0", - "path-to-regexp": "^8.1.0", - "raw-body": "^3", - "tsscmp": "^1.0.6" + "function-bind": "^1.1.2" }, "engines": { - "node": ">=18", - "npm": ">=8.6.0" - }, - "peerDependencies": { - "@types/express": "^5.0.0" + "node": ">= 0.4" } }, - "node_modules/@slack/logger": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-4.0.1.tgz", - "integrity": "sha512-6cmdPrV/RYfd2U0mDGiMK8S7OJqpCTm7enMLRR3edccsPX8j7zXTLnaEF4fhxxJJTAIOil6+qZrnUPTuaLvwrQ==", - "license": "MIT", - "dependencies": { - "@types/node": ">=18" - }, + "node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">= 18", - "npm": ">= 8.6.0" + "node": ">=18.18.0" } }, - "node_modules/@slack/oauth": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@slack/oauth/-/oauth-3.0.5.tgz", - "integrity": "sha512-exqFQySKhNDptWYSWhvRUJ4/+ndu2gayIy7vg/JfmJq3wGtGdHk531P96fAZyBm5c1Le3yaPYqv92rL4COlU3A==", + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, "license": "MIT", "dependencies": { - "@slack/logger": "^4.0.1", - "@slack/web-api": "^7.15.0", - "@types/jsonwebtoken": "^9", - "@types/node": ">=18", - "jsonwebtoken": "^9" + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" }, "engines": { - "node": ">=18", - "npm": ">=8.6.0" + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@slack/socket-mode": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@slack/socket-mode/-/socket-mode-2.0.6.tgz", - "integrity": "sha512-Aj5RO3MoYVJ+b2tUjHUXuA3tiIaCUMOf1Ss5tPiz29XYVUi6qNac2A8ulcU1pUPERpXVHTmT1XW6HzQIO74daQ==", + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, "license": "MIT", - "dependencies": { - "@slack/logger": "^4.0.1", - "@slack/web-api": "^7.15.0", - "@types/node": ">=18", - "@types/ws": "^8", - "eventemitter3": "^5", - "ws": "^8" - }, "engines": { - "node": ">= 18", - "npm": ">= 8.6.0" + "node": ">=4" } }, - "node_modules/@slack/types": { - "version": "2.20.1", - "resolved": "https://registry.npmjs.org/@slack/types/-/types-2.20.1.tgz", - "integrity": "sha512-eWX2mdt1ktpn8+40iiMc404uGrih+2fxiky3zBcPjtXKj6HLRdYlmhrPkJi7JTJm8dpXR6BWVWEDBXtaWMKD6A==", + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, "license": "MIT", - "engines": { - "node": ">= 12.13.0", - "npm": ">= 6.12.0" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/@slack/web-api": { - "version": "7.15.0", - "resolved": "https://registry.npmjs.org/@slack/web-api/-/web-api-7.15.0.tgz", - "integrity": "sha512-va7zYIt3QHG1x9M/jqXXRPFMoOVlVSSRHC5YH+DzKYsrz5xUKOA3lR4THsu/Zxha9N1jOndbKFKLtr0WOPW1Vw==", + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, "license": "MIT", - "dependencies": { - "@slack/logger": "^4.0.1", - "@slack/types": "^2.20.1", - "@types/node": ">=18", - "@types/retry": "0.12.0", - "axios": "^1.13.5", - "eventemitter3": "^5.0.1", - "form-data": "^4.0.4", - "is-electron": "2.2.2", - "is-stream": "^2", - "p-queue": "^6", - "p-retry": "^4", - "retry": "^0.13.1" - }, "engines": { - "node": ">= 18", - "npm": ">= 8.6.0" + "node": ">=0.8.19" } }, - "node_modules/@smithy/abort-controller": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.12.tgz", - "integrity": "sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" }, - "node_modules/@smithy/config-resolver": { - "version": "4.4.11", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.11.tgz", - "integrity": "sha512-YxFiiG4YDAtX7WMN7RuhHZLeTmRRAOyCbr+zB8e3AQzHPnUhS8zXjB1+cniPVQI3xbWsQPM0X2aaIkO/ME0ymw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.12", - "@smithy/types": "^4.13.1", - "@smithy/util-config-provider": "^4.2.2", - "@smithy/util-endpoints": "^3.3.3", - "@smithy/util-middleware": "^4.2.12", - "tslib": "^2.6.2" - }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">=0.10.0" } }, - "node_modules/@smithy/core": { - "version": "3.23.11", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.11.tgz", - "integrity": "sha512-952rGf7hBRnhUIaeLp6q4MptKW8sPFe5VvkoZ5qIzFAtx6c/QZ/54FS3yootsyUSf9gJX/NBqEBNdNR7jMIlpQ==", - "license": "Apache-2.0", + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", "dependencies": { - "@smithy/protocol-http": "^5.3.12", - "@smithy/types": "^4.13.1", - "@smithy/url-parser": "^4.2.12", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-middleware": "^4.2.12", - "@smithy/util-stream": "^4.5.19", - "@smithy/util-utf8": "^4.2.2", - "@smithy/uuid": "^1.1.2", - "tslib": "^2.6.2" + "is-extglob": "^2.1.1" }, "engines": { - "node": ">=18.0.0" + "node": ">=0.10.0" } }, - "node_modules/@smithy/credential-provider-imds": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.12.tgz", - "integrity": "sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.12", - "@smithy/property-provider": "^4.2.12", - "@smithy/types": "^4.13.1", - "@smithy/url-parser": "^4.2.12", - "tslib": "^2.6.2" - }, + "node_modules/is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">=8" } }, - "node_modules/@smithy/eventstream-codec": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.12.tgz", - "integrity": "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.13.1", - "@smithy/util-hex-encoding": "^4.2.2", - "tslib": "^2.6.2" - }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-browser": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.12.tgz", - "integrity": "sha512-XUSuMxlTxV5pp4VpqZf6Sa3vT/Q75FVkLSpSSE3KkWBvAQWeuWt1msTv8fJfgA4/jcJhrbrbMzN1AC/hvPmm5A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/eventstream-serde-universal": "^4.2.12", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" + "node": ">=12" }, - "engines": { - "node": ">=18.0.0" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@smithy/eventstream-serde-config-resolver": { - "version": "4.3.12", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.12.tgz", - "integrity": "sha512-7epsAZ3QvfHkngz6RXQYseyZYHlmWXSTPOfPmXkiS+zA6TBNo1awUaMFL9vxyXlGdoELmCZyZe1nQE+imbmV+Q==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@smithy/eventstream-serde-node": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.12.tgz", - "integrity": "sha512-D1pFuExo31854eAvg89KMn9Oab/wEeJR6Buy32B49A9Ogdtx5fwZPqBHUlDzaCDpycTFk2+fSQgX689Qsk7UGA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/eventstream-serde-universal": "^4.2.12", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", "engines": { - "node": ">=18.0.0" + "node": ">=8" } }, - "node_modules/@smithy/eventstream-serde-universal": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.12.tgz", - "integrity": "sha512-+yNuTiyBACxOJUTvbsNsSOfH9G9oKbaJE1lNL3YHpGcuucl6rPZMi3nrpehpVOVR2E07YqFFmtwpImtpzlouHQ==", - "license": "Apache-2.0", + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "@smithy/eventstream-codec": "^4.2.12", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=10" } }, - "node_modules/@smithy/fetch-http-handler": { - "version": "5.3.15", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.15.tgz", - "integrity": "sha512-T4jFU5N/yiIfrtrsb9uOQn7RdELdM/7HbyLNr6uO/mpkj1ctiVs7CihVr51w4LyQlXWDpXFn4BElf1WmQvZu/A==", - "license": "Apache-2.0", + "node_modules/istanbul-lib-report/node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", "dependencies": { - "@smithy/protocol-http": "^5.3.12", - "@smithy/querystring-builder": "^4.2.12", - "@smithy/types": "^4.13.1", - "@smithy/util-base64": "^4.3.2", - "tslib": "^2.6.2" + "semver": "^7.5.3" }, "engines": { - "node": ">=18.0.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@smithy/hash-node": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.12.tgz", - "integrity": "sha512-QhBYbGrbxTkZ43QoTPrK72DoYviDeg6YKDrHTMJbbC+A0sml3kSjzFtXP7BtbyJnXojLfTQldGdUR0RGD8dA3w==", - "license": "Apache-2.0", + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "@smithy/types": "^4.13.1", - "@smithy/util-buffer-from": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=8" } }, - "node_modules/@smithy/invalid-dependency": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.12.tgz", - "integrity": "sha512-/4F1zb7Z8LOu1PalTdESFHR0RbPwHd3FcaG1sI3UEIriQTWakysgJr65lc1jj6QY5ye7aFsisajotH6UhWfm/g==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/is-array-buffer": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz", - "integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-content-length": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.12.tgz", - "integrity": "sha512-YE58Yz+cvFInWI/wOTrB+DbvUVz/pLn5mC5MvOV4fdRUc6qGwygyngcucRQjAhiCEbmfLOXX0gntSIcgMvAjmA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/protocol-http": "^5.3.12", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-endpoint": { - "version": "4.4.25", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.25.tgz", - "integrity": "sha512-dqjLwZs2eBxIUG6Qtw8/YZ4DvzHGIf0DA18wrgtfP6a50UIO7e2nY0FPdcbv5tVJKqWCCU5BmGMOUwT7Puan+A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.23.11", - "@smithy/middleware-serde": "^4.2.14", - "@smithy/node-config-provider": "^4.3.12", - "@smithy/shared-ini-file-loader": "^4.4.7", - "@smithy/types": "^4.13.1", - "@smithy/url-parser": "^4.2.12", - "@smithy/util-middleware": "^4.2.12", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-retry": { - "version": "4.4.42", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.42.tgz", - "integrity": "sha512-vbwyqHRIpIZutNXZpLAozakzamcINaRCpEy1MYmK6xBeW3xN+TyPRA123GjXnuxZIjc9848MRRCugVMTXxC4Eg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.12", - "@smithy/protocol-http": "^5.3.12", - "@smithy/service-error-classification": "^4.2.12", - "@smithy/smithy-client": "^4.12.5", - "@smithy/types": "^4.13.1", - "@smithy/util-middleware": "^4.2.12", - "@smithy/util-retry": "^4.2.12", - "@smithy/uuid": "^1.1.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-serde": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.14.tgz", - "integrity": "sha512-+CcaLoLa5apzSRtloOyG7lQvkUw2ZDml3hRh4QiG9WyEPfW5Ke/3tPOPiPjUneuT59Tpn8+c3RVaUvvkkwqZwg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.23.11", - "@smithy/protocol-http": "^5.3.12", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-stack": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.12.tgz", - "integrity": "sha512-kruC5gRHwsCOuyCd4ouQxYjgRAym2uDlCvQ5acuMtRrcdfg7mFBg6blaxcJ09STpt3ziEkis6bhg1uwrWU7txw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/node-config-provider": { - "version": "4.3.12", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.12.tgz", - "integrity": "sha512-tr2oKX2xMcO+rBOjobSwVAkV05SIfUKz8iI53rzxEmgW3GOOPOv0UioSDk+J8OpRQnpnhsO3Af6IEBabQBVmiw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/property-provider": "^4.2.12", - "@smithy/shared-ini-file-loader": "^4.4.7", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/node-http-handler": { - "version": "4.4.16", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.16.tgz", - "integrity": "sha512-ULC8UCS/HivdCB3jhi+kLFYe4B5gxH2gi9vHBfEIiRrT2jfKiZNiETJSlzRtE6B26XbBHjPtc8iZKSNqMol9bw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/abort-controller": "^4.2.12", - "@smithy/protocol-http": "^5.3.12", - "@smithy/querystring-builder": "^4.2.12", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/property-provider": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.12.tgz", - "integrity": "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/protocol-http": { - "version": "5.3.12", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.12.tgz", - "integrity": "sha512-fit0GZK9I1xoRlR4jXmbLhoN0OdEpa96ul8M65XdmXnxXkuMxM0Y8HDT0Fh0Xb4I85MBvBClOzgSrV1X2s1Hxw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/querystring-builder": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.12.tgz", - "integrity": "sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.1", - "@smithy/util-uri-escape": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/querystring-parser": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.12.tgz", - "integrity": "sha512-P2OdvrgiAKpkPNKlKUtWbNZKB1XjPxM086NeVhK+W+wI46pIKdWBe5QyXvhUm3MEcyS/rkLvY8rZzyUdmyDZBw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/service-error-classification": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.12.tgz", - "integrity": "sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.4.7", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.7.tgz", - "integrity": "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/signature-v4": { - "version": "5.3.12", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.12.tgz", - "integrity": "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^4.2.2", - "@smithy/protocol-http": "^5.3.12", - "@smithy/types": "^4.13.1", - "@smithy/util-hex-encoding": "^4.2.2", - "@smithy/util-middleware": "^4.2.12", - "@smithy/util-uri-escape": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/smithy-client": { - "version": "4.12.5", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.5.tgz", - "integrity": "sha512-UqwYawyqSr/aog8mnLnfbPurS0gi4G7IYDcD28cUIBhsvWs1+rQcL2IwkUQ+QZ7dibaoRzhNF99fAQ9AUcO00w==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.23.11", - "@smithy/middleware-endpoint": "^4.4.25", - "@smithy/middleware-stack": "^4.2.12", - "@smithy/protocol-http": "^5.3.12", - "@smithy/types": "^4.13.1", - "@smithy/util-stream": "^4.5.19", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/types": { - "version": "4.13.1", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.1.tgz", - "integrity": "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/url-parser": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.12.tgz", - "integrity": "sha512-wOPKPEpso+doCZGIlr+e1lVI6+9VAKfL4kZWFgzVgGWY2hZxshNKod4l2LXS3PRC9otH/JRSjtEHqQ/7eLciRA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/querystring-parser": "^4.2.12", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-base64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.2.tgz", - "integrity": "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-body-length-browser": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.2.tgz", - "integrity": "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-body-length-node": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.3.tgz", - "integrity": "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-buffer-from": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", - "integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-config-provider": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.2.tgz", - "integrity": "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.3.41", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.41.tgz", - "integrity": "sha512-M1w1Ux0rSVvBOxIIiqbxvZvhnjQ+VUjJrugtORE90BbadSTH+jsQL279KRL3Hv0w69rE7EuYkV/4Lepz/NBW9g==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/property-provider": "^4.2.12", - "@smithy/smithy-client": "^4.12.5", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.2.44", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.44.tgz", - "integrity": "sha512-YPze3/lD1KmWuZsl9JlfhcgGLX7AXhSoaCDtiPntUjNW5/YY0lOHjkcgxyE9x/h5vvS1fzDifMGjzqnNlNiqOQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/config-resolver": "^4.4.11", - "@smithy/credential-provider-imds": "^4.2.12", - "@smithy/node-config-provider": "^4.3.12", - "@smithy/property-provider": "^4.2.12", - "@smithy/smithy-client": "^4.12.5", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-endpoints": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.3.3.tgz", - "integrity": "sha512-VACQVe50j0HZPjpwWcjyT51KUQ4AnsvEaQ2lKHOSL4mNLD0G9BjEniQ+yCt1qqfKfiAHRAts26ud7hBjamrwig==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.12", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-hex-encoding": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz", - "integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-middleware": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.12.tgz", - "integrity": "sha512-Er805uFUOvgc0l8nv0e0su0VFISoxhJ/AwOn3gL2NWNY2LUEldP5WtVcRYSQBcjg0y9NfG8JYrCJaYDpupBHJQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-retry": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.12.tgz", - "integrity": "sha512-1zopLDUEOwumjcHdJ1mwBHddubYF8GMQvstVCLC54Y46rqoHwlIU+8ZzUeaBcD+WCJHyDGSeZ2ml9YSe9aqcoQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/service-error-classification": "^4.2.12", - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-stream": { - "version": "4.5.19", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.19.tgz", - "integrity": "sha512-v4sa+3xTweL1CLO2UP0p7tvIMH/Rq1X4KKOxd568mpe6LSLMQCnDHs4uv7m3ukpl3HvcN2JH6jiCS0SNRXKP/w==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/fetch-http-handler": "^5.3.15", - "@smithy/node-http-handler": "^4.4.16", - "@smithy/types": "^4.13.1", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-buffer-from": "^4.2.2", - "@smithy/util-hex-encoding": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-uri-escape": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.2.tgz", - "integrity": "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-utf8": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz", - "integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/uuid": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.2.tgz", - "integrity": "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@snazzah/davey": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/@snazzah/davey/-/davey-0.1.10.tgz", - "integrity": "sha512-J5f7vV5/tnj0xGnqufFRd6qiWn3FcR3iXjpjpEmO2Ok+Io0AASkMaZ3I39TsL45as0Qo5bq9wWuamFQ77PjJ+g==", - "license": "MIT", - "engines": { - "node": ">= 10" - }, - "funding": { - "url": "https://github.com/sponsors/Snazzah" - }, - "optionalDependencies": { - "@snazzah/davey-android-arm-eabi": "0.1.10", - "@snazzah/davey-android-arm64": "0.1.10", - "@snazzah/davey-darwin-arm64": "0.1.10", - "@snazzah/davey-darwin-x64": "0.1.10", - "@snazzah/davey-freebsd-x64": "0.1.10", - "@snazzah/davey-linux-arm-gnueabihf": "0.1.10", - "@snazzah/davey-linux-arm64-gnu": "0.1.10", - "@snazzah/davey-linux-arm64-musl": "0.1.10", - "@snazzah/davey-linux-x64-gnu": "0.1.10", - "@snazzah/davey-linux-x64-musl": "0.1.10", - "@snazzah/davey-wasm32-wasi": "0.1.10", - "@snazzah/davey-win32-arm64-msvc": "0.1.10", - "@snazzah/davey-win32-ia32-msvc": "0.1.10", - "@snazzah/davey-win32-x64-msvc": "0.1.10" - } - }, - "node_modules/@snazzah/davey-android-arm-eabi": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/@snazzah/davey-android-arm-eabi/-/davey-android-arm-eabi-0.1.10.tgz", - "integrity": "sha512-7bwHxSNEI2wVXOT6xnmpnO9SHb2xwAnf9oEdL45dlfVHTgU1Okg5rwGwRvZ2aLVFFbTyecfC8EVZyhpyTkjLSw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@snazzah/davey-android-arm64": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/@snazzah/davey-android-arm64/-/davey-android-arm64-0.1.10.tgz", - "integrity": "sha512-68WUf2LQwQTP9MgPcCqTWwJztJSIk0keGfF2Y/b+MihSDh29fYJl7C0rbz69aUrVCvCC2lYkB/46P8X1kBz7yg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@snazzah/davey-darwin-arm64": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/@snazzah/davey-darwin-arm64/-/davey-darwin-arm64-0.1.10.tgz", - "integrity": "sha512-nYC+DWCGUC1jUGEenCNQE/jJpL/02m0ebY/NvTCQbul5ktI/ShVzgA3kzssEhZvhf6jbH048Rs39wDhp/b24Jg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@snazzah/davey-darwin-x64": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/@snazzah/davey-darwin-x64/-/davey-darwin-x64-0.1.10.tgz", - "integrity": "sha512-0q5Rrcs+O9sSSnPX+A3R3djEQs2nTAtMe5N3lApO6lZas/QNMl6wkEWCvTbDc2cfAYBMSk2jgc1awlRXi4LX3Q==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@snazzah/davey-freebsd-x64": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/@snazzah/davey-freebsd-x64/-/davey-freebsd-x64-0.1.10.tgz", - "integrity": "sha512-/Gq5YDD6Oz8iBqVJLswUnetCv9JCRo1quYX5ujzpAG8zPCNItZo4g4h5p9C+h4Yoay2quWBYhoaVqQKT96bm8g==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@snazzah/davey-linux-arm-gnueabihf": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/@snazzah/davey-linux-arm-gnueabihf/-/davey-linux-arm-gnueabihf-0.1.10.tgz", - "integrity": "sha512-0Z7Vrt0WIbgxws9CeHB9qlueYJlvltI44rUuZmysdi70UcHGxlr7nE3MnzYCr9nRWRegohn8EQPWHMKMDJH2GA==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@snazzah/davey-linux-arm64-gnu": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/@snazzah/davey-linux-arm64-gnu/-/davey-linux-arm64-gnu-0.1.10.tgz", - "integrity": "sha512-xhZQycn4QB+qXhqm/QmZ+kb9MHMXcbjjoPfvcIL4WMQXFG/zUWHW8EiBk7ZTEGMOpeab3F9D1+MlgumglYByUQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@snazzah/davey-linux-arm64-musl": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/@snazzah/davey-linux-arm64-musl/-/davey-linux-arm64-musl-0.1.10.tgz", - "integrity": "sha512-pudzQCP9rZItwW4qHHvciMwtNd9kWH4l73g6Id1LRpe6sc8jiFBV7W+YXITj2PZbI0by6XPfkRP6Dk5IkGOuAw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@snazzah/davey-linux-x64-gnu": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/@snazzah/davey-linux-x64-gnu/-/davey-linux-x64-gnu-0.1.10.tgz", - "integrity": "sha512-DC8qRmk+xJEFNqjxKB46cETKeDQqgUqE5p39KXS2k6Vl/XTi8pw8pXOxrPfYte5neoqlWAVQzbxuLnwpyRJVEQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@snazzah/davey-linux-x64-musl": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/@snazzah/davey-linux-x64-musl/-/davey-linux-x64-musl-0.1.10.tgz", - "integrity": "sha512-wPR5/2QmsF7sR0WUaCwbk4XI3TLcxK9PVK8mhgcAYyuRpbhcVgNGWXs8ulcyMSXve5pFRJAFAuMTGCEb014peg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@snazzah/davey-wasm32-wasi": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/@snazzah/davey-wasm32-wasi/-/davey-wasm32-wasi-0.1.10.tgz", - "integrity": "sha512-SfQavU+eKTDbRmPeLRodrVSfsWq25PYTmH1nIZW3B27L6IkijzjXZZuxiU1ZG1gdI5fB7mwXrOTtx34t+vAG7Q==", - "cpu": [ - "wasm32" - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@snazzah/davey-win32-arm64-msvc": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/@snazzah/davey-win32-arm64-msvc/-/davey-win32-arm64-msvc-0.1.10.tgz", - "integrity": "sha512-Raafk53smYs67wZCY9bQXHXzbaiRMS5QCdjTdin3D9fF5A06T/0Zv1z7/YnaN+O3GSL/Ou3RvynF7SziToYiFQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@snazzah/davey-win32-ia32-msvc": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/@snazzah/davey-win32-ia32-msvc/-/davey-win32-ia32-msvc-0.1.10.tgz", - "integrity": "sha512-pAs43l/DiZ+icqBwxIwNePzuYxFM1ZblVuf7t6vwwSLxvova7vnREnU7qDVjbc5/YTUHOsqYy3S6TpZMzDo2lw==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@snazzah/davey-win32-x64-msvc": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/@snazzah/davey-win32-x64-msvc/-/davey-win32-x64-msvc-0.1.10.tgz", - "integrity": "sha512-kr6148VVBoUT4CtD+5hYshTFRny7R/xQZxXFhFc0fYjtmdMVM8Px9M91olg1JFNxuNzdfMfTufR58Q3wfBocug==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tinyhttp/content-disposition": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/@tinyhttp/content-disposition/-/content-disposition-2.2.4.tgz", - "integrity": "sha512-5Kc5CM2Ysn3vTTArBs2vESUt0AQiWZA86yc1TI3B+lxXmtEq133C1nxXNOgnzhrivdPZIh3zLj5gDnZjoLL5GA==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12.17.0" - }, - "funding": { - "type": "individual", - "url": "https://github.com/tinyhttp/tinyhttp?sponsor=1" - } - }, - "node_modules/@tokenizer/inflate": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", - "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "token-types": "^6.1.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, - "node_modules/@tokenizer/token": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", - "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", - "license": "MIT" - }, - "node_modules/@tootallnate/quickjs-emscripten": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", - "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", - "license": "MIT" - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@types/aws-lambda": { - "version": "8.10.161", - "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.161.tgz", - "integrity": "sha512-rUYdp+MQwSFocxIOcSsYSF3YYYC/uUpMbCY/mbO21vGqfrEYvNSoPyKYDj6RhXXpPfS0KstW9RwG3qXh9sL7FQ==", - "license": "MIT", - "peer": true - }, - "node_modules/@types/body-parser": { - "version": "1.19.6", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", - "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "node_modules/@types/bun": { - "version": "1.3.9", - "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.9.tgz", - "integrity": "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw==", - "license": "MIT", - "optional": true, - "dependencies": { - "bun-types": "1.3.9" - } - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/esrecurse": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", - "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/express": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", - "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^5.0.0", - "@types/serve-static": "^2" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", - "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "node_modules/@types/http-errors": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", - "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "license": "MIT", - "peer": true - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/jsonwebtoken": { - "version": "9.0.10", - "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", - "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", - "license": "MIT", - "dependencies": { - "@types/ms": "*", - "@types/node": "*" - } - }, - "node_modules/@types/long": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", - "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", - "license": "MIT" - }, - "node_modules/@types/mime-types": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.4.tgz", - "integrity": "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==", - "license": "MIT" - }, - "node_modules/@types/ms": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", - "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "25.5.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", - "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" - } - }, - "node_modules/@types/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", - "license": "MIT", - "peer": true - }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "license": "MIT", - "peer": true - }, - "node_modules/@types/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", - "license": "MIT" - }, - "node_modules/@types/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", - "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/http-errors": "*", - "@types/node": "*" - } - }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/yauzl": { - "version": "2.10.3", - "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", - "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@vitest/coverage-v8": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.0.tgz", - "integrity": "sha512-nDWulKeik2bL2Va/Wl4x7DLuTKAXa906iRFooIRPR+huHkcvp9QDkPQ2RJdmjOFrqOqvNfoSQLF68deE3xC3CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.0", - "ast-v8-to-istanbul": "^1.0.0", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-reports": "^3.2.0", - "magicast": "^0.5.2", - "obug": "^2.1.1", - "std-env": "^4.0.0-rc.1", - "tinyrainbow": "^3.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@vitest/browser": "4.1.0", - "vitest": "4.1.0" - }, - "peerDependenciesMeta": { - "@vitest/browser": { - "optional": true - } - } - }, - "node_modules/@vitest/coverage-v8/node_modules/std-env": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", - "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vitest/expect": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", - "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.0", - "@vitest/utils": "4.1.0", - "chai": "^6.2.2", - "tinyrainbow": "^3.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", - "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.0", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", - "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", - "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.0", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", - "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.0", - "@vitest/utils": "4.1.0", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", - "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", - "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.0", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@whiskeysockets/baileys": { - "version": "7.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@whiskeysockets/baileys/-/baileys-7.0.0-rc.9.tgz", - "integrity": "sha512-YFm5gKXfDP9byCXCW3OPHKXLzrAKzolzgVUlRosHHgwbnf2YOO3XknkMm6J7+F0ns8OA0uuSBhgkRHTDtqkacw==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "@cacheable/node-cache": "^1.4.0", - "@hapi/boom": "^9.1.3", - "async-mutex": "^0.5.0", - "libsignal": "git+https://github.com/whiskeysockets/libsignal-node.git", - "lru-cache": "^11.1.0", - "music-metadata": "^11.7.0", - "p-queue": "^9.0.0", - "pino": "^9.6", - "protobufjs": "^7.2.4", - "ws": "^8.13.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "audio-decode": "^2.1.3", - "jimp": "^1.6.0", - "link-preview-js": "^3.0.0", - "sharp": "*" - }, - "peerDependenciesMeta": { - "audio-decode": { - "optional": true - }, - "jimp": { - "optional": true - }, - "link-preview-js": { - "optional": true - } - } - }, - "node_modules/@whiskeysockets/baileys/node_modules/p-queue": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.1.0.tgz", - "integrity": "sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==", - "license": "MIT", - "dependencies": { - "eventemitter3": "^5.0.1", - "p-timeout": "^7.0.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@whiskeysockets/baileys/node_modules/p-timeout": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz", - "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==", - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "license": "MIT", - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/agent-base": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-8.0.0.tgz", - "integrity": "sha512-QT8i0hCz6C/KQ+KTAbSNwCHDGdmUJl2tp2ZpNlGSWCfhUNVbYG2WLE3MdZGBAgXPV4GAvjGMxo+C1hroyxmZEg==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/ansi-escapes": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-6.2.1.tgz", - "integrity": "sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "license": "MIT" - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" - }, - "node_modules/array-ify": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", - "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==", - "dev": true, - "license": "MIT" - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/ast-types": { - "version": "0.13.4", - "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", - "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.1" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/ast-v8-to-istanbul": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", - "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.31", - "estree-walker": "^3.0.3", - "js-tokens": "^10.0.0" - } - }, - "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", - "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/async-mutex": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", - "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", - "license": "MIT", - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/async-retry": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", - "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", - "license": "MIT", - "peer": true, - "dependencies": { - "retry": "0.13.1" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/atomic-sleep": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", - "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/axios": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", - "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.11", - "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" - } - }, - "node_modules/axios-proxy-builder": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/axios-proxy-builder/-/axios-proxy-builder-0.1.2.tgz", - "integrity": "sha512-6uBVsBZzkB3tCC8iyx59mCjQckhB8+GQrI9Cop8eC7ybIsvs/KtnNgEBfRMSEa7GqK2VBGUzgjNYMdPIfotyPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tunnel": "^0.0.6" - } - }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/basic-ftp": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz", - "integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/before-after-hook": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", - "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", - "license": "Apache-2.0", - "peer": true - }, - "node_modules/bignumber.js": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", - "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/boolbase": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", - "license": "ISC" - }, - "node_modules/bottleneck": { - "version": "2.19.5", - "resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz", - "integrity": "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==", - "license": "MIT" - }, - "node_modules/bowser": { - "version": "2.14.1", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", - "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", - "license": "MIT" - }, - "node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "license": "BSD-3-Clause" - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "license": "MIT" - }, - "node_modules/bun-types": { - "version": "1.3.9", - "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.9.tgz", - "integrity": "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg==", - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/cacheable": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.3.tgz", - "integrity": "sha512-iffYMX4zxKp54evOH27fm92hs+DeC1DhXmNVN8Tr94M/iZIV42dqTHSR2Ik4TOSPyOAwKr7Yu3rN9ALoLkbWyQ==", - "license": "MIT", - "dependencies": { - "@cacheable/memory": "^2.0.8", - "@cacheable/utils": "^2.4.0", - "hookified": "^1.15.0", - "keyv": "^5.6.0", - "qified": "^0.6.0" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chmodrp": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/chmodrp/-/chmodrp-1.0.2.tgz", - "integrity": "sha512-TdngOlFV1FLTzU0o1w8MB6/BFywhtLC0SzRTGJU7T9lmdjlCWeMRt1iVo0Ki+ldwNk0BqNiKoc8xpLZEQ8mY1w==", - "license": "MIT", - "peer": true - }, - "node_modules/chokidar": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "license": "MIT", - "dependencies": { - "readdirp": "^5.0.0" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/ci-info": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", - "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "license": "MIT", - "peer": true, - "dependencies": { - "restore-cursor": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-highlight": { - "version": "2.1.11", - "resolved": "https://registry.npmjs.org/cli-highlight/-/cli-highlight-2.1.11.tgz", - "integrity": "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==", - "license": "ISC", - "dependencies": { - "chalk": "^4.0.0", - "highlight.js": "^10.7.1", - "mz": "^2.4.0", - "parse5": "^5.1.1", - "parse5-htmlparser2-tree-adapter": "^6.0.0", - "yargs": "^16.0.0" - }, - "bin": { - "highlight": "bin/highlight" - }, - "engines": { - "node": ">=8.0.0", - "npm": ">=5.0.0" - } - }, - "node_modules/cli-highlight/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cli-highlight/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/cli-highlight/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/cli-highlight/node_modules/cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, - "node_modules/cli-highlight/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cli-highlight/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cli-highlight/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cli-highlight/node_modules/yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", - "license": "MIT", - "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/cli-highlight/node_modules/yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/cliui/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/cmake-js": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/cmake-js/-/cmake-js-8.0.0.tgz", - "integrity": "sha512-YbUP88RDwCvoQkZhRtGURYm9RIpWdtvZuhT87fKNoLjk8kIFIFeARpKfuZQGdwfH99GZpUmqSfcDrK62X7lTgg==", - "license": "MIT", - "peer": true, - "dependencies": { - "debug": "^4.4.3", - "fs-extra": "^11.3.3", - "node-api-headers": "^1.8.0", - "rc": "1.2.8", - "semver": "^7.7.3", - "tar": "^7.5.6", - "url-join": "^4.0.1", - "which": "^6.0.0", - "yargs": "^17.7.2" - }, - "bin": { - "cmake-js": "bin/cmake-js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/commander": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", - "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/compare-func": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", - "integrity": "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-ify": "^1.0.0", - "dot-prop": "^5.1.0" - } - }, - "node_modules/console.table": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/console.table/-/console.table-0.10.0.tgz", - "integrity": "sha512-dPyZofqggxuvSf7WXvNjuRfnsOk1YazkVP8FdxH4tcH2c37wc79/Yl6Bhr7Lsu00KMgy2ql/qCMuNu8xctZM8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "easy-table": "1.1.0" - }, - "engines": { - "node": "> 0.10" - } - }, - "node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/conventional-changelog-angular": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.3.0.tgz", - "integrity": "sha512-DOuBwYSqWzfwuRByY9O4oOIvDlkUCTDzfbOgcSbkY+imXXj+4tmrEFao3K+FxemClYfYnZzsvudbwrhje9VHDA==", - "dev": true, - "license": "ISC", - "dependencies": { - "compare-func": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/conventional-changelog-conventionalcommits": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-9.3.0.tgz", - "integrity": "sha512-kYFx6gAyjSIMwNtASkI3ZE99U1fuVDJr0yTYgVy+I2QG46zNZfl2her+0+eoviG82c5WQvW1jMt1eOQTeJLodA==", - "dev": true, - "license": "ISC", - "dependencies": { - "compare-func": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/conventional-commits-parser": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.3.0.tgz", - "integrity": "sha512-RfOq/Cqy9xV9bOA8N+ZH6DlrDR+5S3Mi0B5kACEjESpE+AviIpAptx9a9cFpWCCvgRtWT+0BbUw+e1BZfts9jg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@simple-libs/stream-utils": "^1.2.0", - "meow": "^13.0.0" - }, - "bin": { - "conventional-commits-parser": "dist/cli/index.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "license": "MIT" - }, - "node_modules/cosmiconfig": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", - "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "env-paths": "^2.2.1", - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/cosmiconfig-typescript-loader": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-6.2.0.tgz", - "integrity": "sha512-GEN39v7TgdxgIoNcdkRE3uiAzQt3UXLyHbRHD6YoL048XAeOomyxaP+Hh/+2C6C2wYjxJ2onhJcsQp+L4YEkVQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "jiti": "^2.6.1" - }, - "engines": { - "node": ">=v18" - }, - "peerDependencies": { - "@types/node": "*", - "cosmiconfig": ">=9", - "typescript": ">=5" - } - }, - "node_modules/croner": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/croner/-/croner-10.0.1.tgz", - "integrity": "sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==", - "funding": [ - { - "type": "other", - "url": "https://paypal.me/hexagonpp" - }, - { - "type": "github", - "url": "https://github.com/sponsors/hexagon" - } - ], - "license": "MIT", - "engines": { - "node": ">=18.0" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/cross-spawn/node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/cross-spawn/node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/css-select": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", - "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0", - "css-what": "^6.1.0", - "domhandler": "^5.0.2", - "domutils": "^3.0.1", - "nth-check": "^2.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/css-what": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", - "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", - "license": "BSD-2-Clause", - "engines": { - "node": ">= 6" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/cssom": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", - "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", - "license": "MIT" - }, - "node_modules/curve25519-js": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/curve25519-js/-/curve25519-js-0.0.4.tgz", - "integrity": "sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==", - "license": "MIT" - }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/defaults": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", - "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "clone": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/degenerator": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", - "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", - "license": "MIT", - "dependencies": { - "ast-types": "^0.13.4", - "escodegen": "^2.1.0", - "esprima": "^4.0.1" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/diff": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", - "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/discord-api-types": { - "version": "0.38.42", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.42.tgz", - "integrity": "sha512-qs1kya7S84r5RR8m9kgttywGrmmoHaRifU1askAoi+wkoSefLpZP6aGXusjNw5b0jD3zOg3LTwUa3Tf2iHIceQ==", - "license": "MIT", - "workspaces": [ - "scripts/actions/documentation" - ] - }, - "node_modules/dom-serializer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", - "entities": "^4.2.0" - }, - "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" - } - }, - "node_modules/domelementtype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "BSD-2-Clause" - }, - "node_modules/domhandler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", - "license": "BSD-2-Clause", - "dependencies": { - "domelementtype": "^2.3.0" - }, - "engines": { - "node": ">= 4" - }, - "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" - } - }, - "node_modules/domutils": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", - "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", - "license": "BSD-2-Clause", - "dependencies": { - "dom-serializer": "^2.0.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3" - }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" - } - }, - "node_modules/dot-prop": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", - "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-obj": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/dotenv": { - "version": "17.3.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", - "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "license": "MIT" - }, - "node_modules/easy-table": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/easy-table/-/easy-table-1.1.0.tgz", - "integrity": "sha512-oq33hWOSSnl2Hoh00tZWaIPi1ievrD9aFG82/IgjlycAnW9hHx5PkJiXpxPsgEE+H7BsbVQXFVFST8TEXS6/pA==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "wcwidth": ">=1.0.1" - } - }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/env-var": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/env-var/-/env-var-7.5.0.tgz", - "integrity": "sha512-mKZOzLRN0ETzau2W2QXefbFjo5EF4yWq28OyKb9ICdeNhHJlOE/pHHnz4hdYJ9cNZXcJHo5xN4OT4pzuSHSNvA==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/error-ex": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-module-lexer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/escodegen": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", - "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", - "license": "BSD-2-Clause", - "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^5.2.0", - "esutils": "^2.0.2" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" - }, - "engines": { - "node": ">=6.0" - }, - "optionalDependencies": { - "source-map": "~0.6.1" - } - }, - "node_modules/eslint": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.1.0.tgz", - "integrity": "sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.3", - "@eslint/config-helpers": "^0.5.3", - "@eslint/core": "^1.1.1", - "@eslint/plugin-kit": "^0.6.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.14.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^9.1.2", - "eslint-visitor-keys": "^5.0.1", - "espree": "^11.2.0", - "esquery": "^1.7.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.4", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-scope": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", - "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@types/esrecurse": "^4.3.1", - "@types/estree": "^1.0.8", - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/eslint/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/eslint/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/espree": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", - "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.16.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^5.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "license": "MIT" - }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT" - }, - "node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "license": "BSD-2-Clause", - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, - "node_modules/fast-content-type-parse": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", - "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "peer": true - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fast-xml-builder": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.3.tgz", - "integrity": "sha512-1o60KoFw2+LWKQu3IdcfcFlGTW4dpqEWmjhYec6H82AYZU2TVBXep6tMl8Z1Y+wM+ZrzCwe3BZ9Vyd9N2rIvmg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "path-expression-matcher": "^1.1.3" - } - }, - "node_modules/fast-xml-parser": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz", - "integrity": "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "fast-xml-builder": "^1.0.0", - "strnum": "^2.1.2" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "license": "MIT", - "dependencies": { - "pend": "~1.2.0" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - }, - "engines": { - "node": "^12.20 || >= 14.13" - } - }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/file-type": { - "version": "21.3.2", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.2.tgz", - "integrity": "sha512-DLkUvGwep3poOV2wpzbHCOnSKGk1LzyXTv+aHFgN2VFl96wnp8YA9YjO2qPzg5PuL8q/SW9Pdi6WTkYOIh995w==", - "license": "MIT", - "dependencies": { - "@tokenizer/inflate": "^0.4.1", - "strtok3": "^10.3.4", - "token-types": "^6.1.1", - "uint8array-extras": "^1.4.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sindresorhus/file-type?sponsor=1" - } - }, - "node_modules/filename-reserved-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/filename-reserved-regex/-/filename-reserved-regex-3.0.0.tgz", - "integrity": "sha512-hn4cQfU6GOT/7cFHXBqeBg2TbrMBgdD0kcjLhvSQYYwm3s4B6cjvBfb7nBALJLAXqmU5xajSa7X2NnUud/VCdw==", - "license": "MIT", - "peer": true, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/filenamify": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/filenamify/-/filenamify-6.0.0.tgz", - "integrity": "sha512-vqIlNogKeyD3yzrm0yhRMQg8hOVwYcYRfjEoODd49iCprMn4HL85gK3HcykQE53EPIpX3HcAbGA5ELQv216dAQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "filename-reserved-regex": "^3.0.0" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flat-cache/node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", - "dev": true, - "license": "ISC" - }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/foreground-child/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/form-data/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/form-data/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "license": "MIT", - "dependencies": { - "fetch-blob": "^3.1.2" - }, - "engines": { - "node": ">=12.20.0" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/fs-extra": { - "version": "11.3.4", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", - "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", - "license": "MIT", - "peer": true, - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gaxios": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", - "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", - "license": "Apache-2.0", - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^7.0.1", - "node-fetch": "^3.3.2", - "rimraf": "^5.0.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/gaxios/node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/gaxios/node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/gcp-metadata": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", - "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", - "license": "Apache-2.0", - "dependencies": { - "gaxios": "^7.0.0", - "google-logging-utils": "^1.0.0", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-east-asian-width": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", - "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "license": "MIT", - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-uri": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", - "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", - "license": "MIT", - "dependencies": { - "basic-ftp": "^5.0.2", - "data-uri-to-buffer": "^6.0.2", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/get-uri/node_modules/data-uri-to-buffer": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", - "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/git-raw-commits": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-5.0.1.tgz", - "integrity": "sha512-Y+csSm2GD/PCSh6Isd/WiMjNAydu0VBiG9J7EdQsNA5P9uXvLayqjmTsNlK5Gs9IhblFZqOU0yid5Il5JPoLiQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@conventional-changelog/git-client": "^2.6.0", - "meow": "^13.0.0" - }, - "bin": { - "git-raw-commits": "src/cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/global-directory": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", - "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ini": "4.1.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/global-directory/node_modules/ini": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", - "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/google-auth-library": { - "version": "10.6.1", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.1.tgz", - "integrity": "sha512-5awwuLrzNol+pFDmKJd0dKtZ0fPLAtoA5p7YO4ODsDu6ONJUVqbYwvv8y2ZBO5MBNp9TJXigB19710kYpBPdtA==", - "license": "Apache-2.0", - "dependencies": { - "base64-js": "^1.3.0", - "ecdsa-sig-formatter": "^1.0.11", - "gaxios": "7.1.3", - "gcp-metadata": "8.1.2", - "google-logging-utils": "1.1.3", - "jws": "^4.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/google-logging-utils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", - "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC" - }, - "node_modules/grammy": { - "version": "1.41.1", - "resolved": "https://registry.npmjs.org/grammy/-/grammy-1.41.1.tgz", - "integrity": "sha512-wcHAQ1e7svL3fJMpDchcQVcWUmywhuepOOjHUHmMmWAwUJEIyK5ea5sbSjZd+Gy1aMpZeP8VYJa+4tP+j1YptQ==", - "license": "MIT", - "dependencies": { - "@grammyjs/types": "3.25.0", - "abort-controller": "^3.0.0", - "debug": "^4.4.3", - "node-fetch": "^2.7.0" - }, - "engines": { - "node": "^12.20.0 || >=14.13.1" - } - }, - "node_modules/grammy/node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hashery": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.0.tgz", - "integrity": "sha512-nhQ6ExaOIqti2FDWoEMWARUqIKyjr2VcZzXShrI+A3zpeiuPWzx6iPftt44LhP74E5sW36B75N6VHbvRtpvO6Q==", - "license": "MIT", - "dependencies": { - "hookified": "^1.14.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/highlight.js": { - "version": "10.7.3", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", - "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", - "license": "BSD-3-Clause", - "engines": { - "node": "*" - } - }, - "node_modules/hono": { - "version": "4.12.7", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz", - "integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==", - "license": "MIT", - "engines": { - "node": ">=16.9.0" - } - }, - "node_modules/hookified": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", - "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", - "license": "MIT" - }, - "node_modules/hosted-git-info": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.2.tgz", - "integrity": "sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==", - "license": "ISC", - "dependencies": { - "lru-cache": "^11.1.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/html-escaper": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", - "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", - "license": "MIT" - }, - "node_modules/htmlparser2": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", - "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.2.2", - "entities": "^7.0.1" - } - }, - "node_modules/htmlparser2/node_modules/entities": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/http-proxy-agent/node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-8.0.0.tgz", - "integrity": "sha512-YYeW+iCnAS3xhvj2dvVoWgsbca3RfQy/IlaNHHOtDmU0jMqPI9euIq3Y9BJETdxk16h9NHHCKqp/KB9nIMStCQ==", - "license": "MIT", - "dependencies": { - "agent-base": "8.0.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/immediate": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", - "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", - "license": "MIT" - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/import-fresh/node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/import-meta-resolve": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", - "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC", - "peer": true - }, - "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/ipaddr.js": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", - "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/ipull": { - "version": "3.9.5", - "resolved": "https://registry.npmjs.org/ipull/-/ipull-3.9.5.tgz", - "integrity": "sha512-5w/yZB5lXmTfsvNawmvkCjYo4SJNuKQz/av8TC1UiOyfOHyaM+DReqbpU2XpWYfmY+NIUbRRH8PUAWsxaS+IfA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@tinyhttp/content-disposition": "^2.2.0", - "async-retry": "^1.3.3", - "chalk": "^5.3.0", - "ci-info": "^4.0.0", - "cli-spinners": "^2.9.2", - "commander": "^10.0.0", - "eventemitter3": "^5.0.1", - "filenamify": "^6.0.0", - "fs-extra": "^11.1.1", - "is-unicode-supported": "^2.0.0", - "lifecycle-utils": "^2.0.1", - "lodash.debounce": "^4.0.8", - "lowdb": "^7.0.1", - "pretty-bytes": "^6.1.0", - "pretty-ms": "^8.0.0", - "sleep-promise": "^9.1.0", - "slice-ansi": "^7.1.0", - "stdout-update": "^4.0.1", - "strip-ansi": "^7.1.0" - }, - "bin": { - "ipull": "dist/cli/cli.js" - }, - "engines": { - "node": ">=18.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/ido-pluto/ipull?sponsor=1" - }, - "optionalDependencies": { - "@reflink/reflink": "^0.1.16" - } - }, - "node_modules/ipull/node_modules/lifecycle-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/lifecycle-utils/-/lifecycle-utils-2.1.0.tgz", - "integrity": "sha512-AnrXnE2/OF9PHCyFg0RSqsnQTzV991XaZA/buhFDoc58xU7rhSCDgCz/09Lqpsn4MpoPHt7TRAXV1kWZypFVsA==", - "license": "MIT", - "peer": true - }, - "node_modules/ipull/node_modules/parse-ms": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-3.0.0.tgz", - "integrity": "sha512-Tpb8Z7r7XbbtBTrM9UhpkzzaMrqA2VXMT3YChzYltwV3P3pM6t8wl7TvpMnSTosz1aQAdVib7kdoys7vYOPerw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ipull/node_modules/pretty-ms": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-8.0.0.tgz", - "integrity": "sha512-ASJqOugUF1bbzI35STMBUpZqdfYKlJugy6JBziGi2EE+AL5JPJGSzvpeVXojxrr0ViUYoToUjb5kjSEGf7Y83Q==", - "license": "MIT", - "peer": true, - "dependencies": { - "parse-ms": "^3.0.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ipull/node_modules/slice-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", - "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-electron": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/is-electron/-/is-electron-2.2.2.tgz", - "integrity": "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==", - "license": "MIT" - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-interactive": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", - "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-obj": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", - "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-plain-obj": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" - }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-unicode-supported": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT" - }, - "node_modules/isexe": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", - "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", - "license": "BlueOak-1.0.0", - "peer": true, - "engines": { - "node": ">=20" - } - }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-report/node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-reports/node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-bigint": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", - "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", - "license": "MIT", - "dependencies": { - "bignumber.js": "^9.0.0" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-to-ts": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", - "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.3", - "ts-algebra": "^2.0.0" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-with-bigint": { - "version": "3.5.7", - "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.7.tgz", - "integrity": "sha512-7ei3MdAI5+fJPVnKlW77TKNKwQ5ppSzWvhPuSuINT/GYW9ZOC1eRKOuhV9yHG5aEsUPj9BBx5JIekkmoLHxZOw==", - "license": "MIT", - "peer": true - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "license": "MIT", - "peer": true, - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/jsonwebtoken": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", - "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", - "license": "MIT", - "dependencies": { - "jws": "^4.0.1", - "lodash.includes": "^4.3.0", - "lodash.isboolean": "^3.0.3", - "lodash.isinteger": "^4.0.4", - "lodash.isnumber": "^3.0.3", - "lodash.isplainobject": "^4.0.6", - "lodash.isstring": "^4.0.1", - "lodash.once": "^4.0.0", - "ms": "^2.1.1", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=12", - "npm": ">=6" - } - }, - "node_modules/jszip": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", - "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", - "license": "(MIT OR GPL-3.0-or-later)", - "dependencies": { - "lie": "~3.3.0", - "pako": "~1.0.2", - "readable-stream": "~2.3.6", - "setimmediate": "^1.0.5" - } - }, - "node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jws": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", - "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", - "license": "MIT", - "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/keyv": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", - "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", - "license": "MIT", - "dependencies": { - "@keyv/serialize": "^1.1.1" - } - }, - "node_modules/koffi": { - "version": "2.15.2", - "resolved": "https://registry.npmjs.org/koffi/-/koffi-2.15.2.tgz", - "integrity": "sha512-r9tjJLVRSOhCRWdVyQlF3/Ugzeg13jlzS4czS82MAgLff4W+BcYOW7g8Y62t9O5JYjYOLAjAovAZDNlDfZNu+g==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/libsignal": { - "name": "@whiskeysockets/libsignal-node", - "version": "2.0.1", - "resolved": "git+ssh://git@github.com/whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67", - "license": "GPL-3.0", - "dependencies": { - "curve25519-js": "^0.0.4", - "protobufjs": "6.8.8" - } - }, - "node_modules/libsignal/node_modules/@types/node": { - "version": "10.17.60", - "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", - "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==", - "license": "MIT" - }, - "node_modules/libsignal/node_modules/long": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", - "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", - "license": "Apache-2.0" - }, - "node_modules/libsignal/node_modules/protobufjs": { - "version": "6.8.8", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.8.8.tgz", - "integrity": "sha512-AAmHtD5pXgZfi7GMpllpO3q1Xw1OYldr+dMUlAnffGTAhqkg72WdmSY71uKBF/JuyiKs8psYbtKrhi0ASCD8qw==", - "hasInstallScript": true, - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/long": "^4.0.0", - "@types/node": "^10.1.0", - "long": "^4.0.0" - }, - "bin": { - "pbjs": "bin/pbjs", - "pbts": "bin/pbts" - } - }, - "node_modules/lie": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", - "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", - "license": "MIT", - "dependencies": { - "immediate": "~3.0.5" - } - }, - "node_modules/lifecycle-utils": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/lifecycle-utils/-/lifecycle-utils-3.1.1.tgz", - "integrity": "sha512-gNd3OvhFNjHykJE3uGntz7UuPzWlK9phrIdXxU9Adis0+ExkwnZibfxCJWiWWZ+a6VbKiZrb+9D9hCQWd4vjTg==", - "license": "MIT", - "peer": true - }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, - "license": "MIT" - }, - "node_modules/linkedom": { - "version": "0.18.12", - "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.12.tgz", - "integrity": "sha512-jalJsOwIKuQJSeTvsgzPe9iJzyfVaEJiEXl+25EkKevsULHvMJzpNqwvj1jOESWdmgKDiXObyjOYwlUqG7wo1Q==", - "license": "ISC", - "dependencies": { - "css-select": "^5.1.0", - "cssom": "^0.5.0", - "html-escaper": "^3.0.3", - "htmlparser2": "^10.0.0", - "uhyphen": "^0.2.0" - }, - "engines": { - "node": ">=16" - }, - "peerDependencies": { - "canvas": ">= 2" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, - "node_modules/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", - "license": "MIT", - "dependencies": { - "uc.micro": "^2.0.0" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash.camelcase": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", - "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.debounce": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", - "license": "MIT", - "peer": true - }, - "node_modules/lodash.identity": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/lodash.identity/-/lodash.identity-3.0.0.tgz", - "integrity": "sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q==", - "license": "MIT" - }, - "node_modules/lodash.includes": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", - "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", - "license": "MIT" - }, - "node_modules/lodash.isboolean": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", - "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", - "license": "MIT" - }, - "node_modules/lodash.isinteger": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", - "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", - "license": "MIT" - }, - "node_modules/lodash.isnumber": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", - "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", - "license": "MIT" - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "license": "MIT" - }, - "node_modules/lodash.isstring": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", - "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", - "license": "MIT" - }, - "node_modules/lodash.kebabcase": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", - "integrity": "sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "license": "MIT" - }, - "node_modules/lodash.mergewith": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", - "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.once": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", - "license": "MIT" - }, - "node_modules/lodash.pickby": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/lodash.pickby/-/lodash.pickby-4.6.0.tgz", - "integrity": "sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q==", - "license": "MIT" - }, - "node_modules/lodash.snakecase": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", - "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.startcase": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz", - "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.upperfirst": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz", - "integrity": "sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==", - "dev": true, - "license": "MIT" - }, - "node_modules/log-symbols": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", - "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==", - "license": "MIT", - "peer": true, - "dependencies": { - "is-unicode-supported": "^2.0.0", - "yoctocolors": "^2.1.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0" - }, - "node_modules/lowdb": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/lowdb/-/lowdb-7.0.1.tgz", - "integrity": "sha512-neJAj8GwF0e8EpycYIDFqEPcx9Qz4GUho20jWFR7YiFeXzF1YMLdxB36PypcTSPMA+4+LvgyMacYhlr18Zlymw==", - "license": "MIT", - "peer": true, - "dependencies": { - "steno": "^4.0.2" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/typicode" - } - }, - "node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/magicast": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", - "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", - "source-map-js": "^1.2.1" - } - }, - "node_modules/markdown-it": { - "version": "14.1.1", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", - "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1", - "entities": "^4.4.0", - "linkify-it": "^5.0.0", - "mdurl": "^2.0.0", - "punycode.js": "^2.3.1", - "uc.micro": "^2.1.0" - }, - "bin": { - "markdown-it": "bin/markdown-it.mjs" - } - }, - "node_modules/marked": { - "version": "15.0.12", - "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", - "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", - "license": "MIT", - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/mdurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", - "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", - "license": "MIT" - }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/meow": { - "version": "13.2.0", - "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", - "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minizlib": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", - "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", - "license": "MIT", - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/music-metadata": { - "version": "11.12.3", - "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.12.3.tgz", - "integrity": "sha512-n6hSTZkuD59qWgHh6IP5dtDlDZQXoxk/bcA85Jywg8Z1iFrlNgl2+GTFgjZyn52W5UgQpV42V4XqrQZZAMbZTQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - }, - { - "type": "buymeacoffee", - "url": "https://buymeacoffee.com/borewit" - } - ], - "license": "MIT", - "dependencies": { - "@borewit/text-codec": "^0.2.2", - "@tokenizer/token": "^0.3.0", - "content-type": "^1.0.5", - "debug": "^4.4.3", - "file-type": "^21.3.1", - "media-typer": "^1.1.0", - "strtok3": "^10.3.4", - "token-types": "^6.1.2", - "uint8array-extras": "^1.5.0", - "win-guid": "^0.2.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, - "node_modules/nanoid": { - "version": "5.1.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.7.tgz", - "integrity": "sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "peer": true, - "bin": { - "nanoid": "bin/nanoid.js" - }, - "engines": { - "node": "^18 || >=20" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/netmask": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", - "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/node-addon-api": { - "version": "8.6.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.6.0.tgz", - "integrity": "sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==", - "license": "MIT", - "peer": true, - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, - "node_modules/node-api-headers": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/node-api-headers/-/node-api-headers-1.8.0.tgz", - "integrity": "sha512-jfnmiKWjRAGbdD1yQS28bknFM1tbHC1oucyuMPjmkEs+kpiu76aRs40WlTmBmyEgzDM76ge1DQ7XJ3R5deiVjQ==", - "license": "MIT", - "peer": true - }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "engines": { - "node": ">=10.5.0" - } - }, - "node_modules/node-edge-tts": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/node-edge-tts/-/node-edge-tts-1.2.10.tgz", - "integrity": "sha512-bV2i4XU54D45+US0Zm1HcJRkifuB3W438dWyuJEHLQdKxnuqlI1kim2MOvR6Q3XUQZvfF9PoDyR1Rt7aeXhPdQ==", - "license": "MIT", - "dependencies": { - "https-proxy-agent": "^7.0.1", - "ws": "^8.13.0", - "yargs": "^17.7.2" - }, - "bin": { - "node-edge-tts": "bin.js" - } - }, - "node_modules/node-edge-tts/node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/node-edge-tts/node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "license": "MIT", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, - "node_modules/node-llama-cpp": { - "version": "3.16.2", - "resolved": "https://registry.npmjs.org/node-llama-cpp/-/node-llama-cpp-3.16.2.tgz", - "integrity": "sha512-ovhuTaXSWfcoyfI8ljWxO2Rg63mNxqQQAbDGkXRhlgsL7UjPqm2Nsy1bTNa0ZaQRg3vezG4agnCJTImrICY/0A==", - "hasInstallScript": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@huggingface/jinja": "^0.5.5", - "async-retry": "^1.3.3", - "bytes": "^3.1.2", - "chalk": "^5.6.2", - "chmodrp": "^1.0.2", - "cmake-js": "^8.0.0", - "cross-spawn": "^7.0.6", - "env-var": "^7.5.0", - "filenamify": "^6.0.0", - "fs-extra": "^11.3.0", - "ignore": "^7.0.4", - "ipull": "^3.9.5", - "is-unicode-supported": "^2.1.0", - "lifecycle-utils": "^3.1.1", - "log-symbols": "^7.0.1", - "nanoid": "^5.1.6", - "node-addon-api": "^8.5.0", - "octokit": "^5.0.5", - "ora": "^9.3.0", - "pretty-ms": "^9.3.0", - "proper-lockfile": "^4.1.2", - "semver": "^7.7.1", - "simple-git": "^3.31.1", - "slice-ansi": "^8.0.0", - "stdout-update": "^4.0.1", - "strip-ansi": "^7.1.2", - "validate-npm-package-name": "^7.0.2", - "which": "^6.0.1", - "yargs": "^17.7.2" - }, - "bin": { - "nlc": "dist/cli/cli.js", - "node-llama-cpp": "dist/cli/cli.js" - }, - "engines": { - "node": ">=20.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/giladgd" - }, - "optionalDependencies": { - "@node-llama-cpp/linux-arm64": "3.16.2", - "@node-llama-cpp/linux-armv7l": "3.16.2", - "@node-llama-cpp/linux-x64": "3.16.2", - "@node-llama-cpp/linux-x64-cuda": "3.16.2", - "@node-llama-cpp/linux-x64-cuda-ext": "3.16.2", - "@node-llama-cpp/linux-x64-vulkan": "3.16.2", - "@node-llama-cpp/mac-arm64-metal": "3.16.2", - "@node-llama-cpp/mac-x64": "3.16.2", - "@node-llama-cpp/win-arm64": "3.16.2", - "@node-llama-cpp/win-x64": "3.16.2", - "@node-llama-cpp/win-x64-cuda": "3.16.2", - "@node-llama-cpp/win-x64-cuda-ext": "3.16.2", - "@node-llama-cpp/win-x64-vulkan": "3.16.2" - }, - "peerDependencies": { - "typescript": ">=5.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/node-readable-to-web-readable-stream": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz", - "integrity": "sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==", - "license": "MIT", - "optional": true - }, - "node_modules/nth-check": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0" - }, - "funding": { - "url": "https://github.com/fb55/nth-check?sponsor=1" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT" - }, - "node_modules/octokit": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/octokit/-/octokit-5.0.5.tgz", - "integrity": "sha512-4+/OFSqOjoyULo7eN7EA97DE0Xydj/PW5aIckxqQIoFjFwqXKuFCvXUJObyJfBF9Khu4RL/jlDRI9FPaMGfPnw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@octokit/app": "^16.1.2", - "@octokit/core": "^7.0.6", - "@octokit/oauth-app": "^8.0.3", - "@octokit/plugin-paginate-graphql": "^6.0.0", - "@octokit/plugin-paginate-rest": "^14.0.0", - "@octokit/plugin-rest-endpoint-methods": "^17.0.0", - "@octokit/plugin-retry": "^8.0.3", - "@octokit/plugin-throttling": "^11.0.3", - "@octokit/request-error": "^7.0.2", - "@octokit/types": "^16.0.0", - "@octokit/webhooks": "^14.0.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/on-exit-leak-free": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", - "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "mimic-function": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/openai": { - "version": "6.26.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", - "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", - "license": "Apache-2.0", - "bin": { - "openai": "bin/cli" - }, - "peerDependencies": { - "ws": "^8.18.0", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "ws": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, - "node_modules/openclaw": { - "version": "2026.3.11", - "resolved": "https://registry.npmjs.org/openclaw/-/openclaw-2026.3.11.tgz", - "integrity": "sha512-bxwiBmHPakwfpY5tqC9lrV5TCu5PKf0c1bHNc3nhrb+pqKcPEWV4zOjDVFLQUHr98ihgWA+3pacy4b3LQ8wduQ==", - "license": "MIT", - "dependencies": { - "@agentclientprotocol/sdk": "0.16.1", - "@aws-sdk/client-bedrock": "^3.1007.0", - "@buape/carbon": "0.0.0-beta-20260216184201", - "@clack/prompts": "^1.1.0", - "@discordjs/voice": "^0.19.1", - "@grammyjs/runner": "^2.0.3", - "@grammyjs/transformer-throttler": "^1.2.1", - "@homebridge/ciao": "^1.3.5", - "@larksuiteoapi/node-sdk": "^1.59.0", - "@line/bot-sdk": "^10.6.0", - "@lydell/node-pty": "1.2.0-beta.3", - "@mariozechner/pi-agent-core": "0.57.1", - "@mariozechner/pi-ai": "0.57.1", - "@mariozechner/pi-coding-agent": "0.57.1", - "@mariozechner/pi-tui": "0.57.1", - "@mozilla/readability": "^0.6.0", - "@sinclair/typebox": "0.34.48", - "@slack/bolt": "^4.6.0", - "@slack/web-api": "^7.14.1", - "@whiskeysockets/baileys": "7.0.0-rc.9", - "ajv": "^8.18.0", - "chalk": "^5.6.2", - "chokidar": "^5.0.0", - "cli-highlight": "^2.1.11", - "commander": "^14.0.3", - "croner": "^10.0.1", - "discord-api-types": "^0.38.42", - "dotenv": "^17.3.1", - "express": "^5.2.1", - "file-type": "^21.3.1", - "grammy": "^1.41.1", - "hono": "4.12.7", - "https-proxy-agent": "^8.0.0", - "ipaddr.js": "^2.3.0", - "jiti": "^2.6.1", - "json5": "^2.2.3", - "jszip": "^3.10.1", - "linkedom": "^0.18.12", - "long": "^5.3.2", - "markdown-it": "^14.1.1", - "node-edge-tts": "^1.2.10", - "opusscript": "^0.1.1", - "osc-progress": "^0.3.0", - "pdfjs-dist": "^5.5.207", - "playwright-core": "1.58.2", - "qrcode-terminal": "^0.12.0", - "sharp": "^0.34.5", - "sqlite-vec": "0.1.7-alpha.2", - "tar": "7.5.11", - "tslog": "^4.10.2", - "undici": "^7.22.0", - "ws": "^8.19.0", - "yaml": "^2.8.2", - "zod": "^4.3.6" - }, - "bin": { - "openclaw": "openclaw.mjs" - }, - "engines": { - "node": ">=22.12.0" - }, - "peerDependencies": { - "@napi-rs/canvas": "^0.1.89", - "node-llama-cpp": "3.16.2" - } - }, - "node_modules/openclaw/node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/opusscript": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/opusscript/-/opusscript-0.1.1.tgz", - "integrity": "sha512-mL0fZZOUnXdZ78woRXp18lApwpp0lF5tozJOD1Wut0dgrA9WuQTgSels/CSmFleaAZrJi/nci5KOVtbuxeWoQA==", - "license": "MIT" - }, - "node_modules/ora": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/ora/-/ora-9.3.0.tgz", - "integrity": "sha512-lBX72MWFduWEf7v7uWf5DHp9Jn5BI8bNPGuFgtXMmr2uDz2Gz2749y3am3agSDdkhHPHYmmxEGSKH85ZLGzgXw==", - "license": "MIT", - "peer": true, - "dependencies": { - "chalk": "^5.6.2", - "cli-cursor": "^5.0.0", - "cli-spinners": "^3.2.0", - "is-interactive": "^2.0.0", - "is-unicode-supported": "^2.1.0", - "log-symbols": "^7.0.1", - "stdin-discarder": "^0.3.1", - "string-width": "^8.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora/node_modules/cli-spinners": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz", - "integrity": "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/osc-progress": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/osc-progress/-/osc-progress-0.3.0.tgz", - "integrity": "sha512-4/8JfsetakdeEa4vAYV45FW20aY+B/+K8NEXp5Eiar3wR8726whgHrbSg5Ar/ZY1FLJ/AGtUqV7W2IVF+Gvp9A==", - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/p-finally": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", - "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-queue": { - "version": "6.6.2", - "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", - "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", - "license": "MIT", - "dependencies": { - "eventemitter3": "^4.0.4", - "p-timeout": "^3.2.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-queue/node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", - "license": "MIT" - }, - "node_modules/p-retry": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", - "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", - "license": "MIT", - "dependencies": { - "@types/retry": "0.12.0", - "retry": "^0.13.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/p-timeout": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", - "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", - "license": "MIT", - "dependencies": { - "p-finally": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pac-proxy-agent": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", - "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", - "license": "MIT", - "dependencies": { - "@tootallnate/quickjs-emscripten": "^0.23.0", - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "get-uri": "^6.0.1", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.6", - "pac-resolver": "^7.0.1", - "socks-proxy-agent": "^8.0.5" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/pac-proxy-agent/node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/pac-proxy-agent/node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/pac-resolver": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", - "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", - "license": "MIT", - "dependencies": { - "degenerator": "^5.0.0", - "netmask": "^2.0.2" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "license": "BlueOak-1.0.0" - }, - "node_modules/pako": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", - "license": "(MIT AND Zlib)" - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parse-ms": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", - "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parse5": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", - "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==", - "license": "MIT" - }, - "node_modules/parse5-htmlparser2-tree-adapter": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz", - "integrity": "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==", - "license": "MIT", - "dependencies": { - "parse5": "^6.0.1" - } - }, - "node_modules/parse5-htmlparser2-tree-adapter/node_modules/parse5": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", - "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", - "license": "MIT" - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/partial-json": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", - "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", - "license": "MIT" - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-expression-matcher": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz", - "integrity": "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "node_modules/istanbul-reports/node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true, "license": "MIT" }, - "node_modules/pdfjs-dist": { - "version": "5.5.207", - "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.5.207.tgz", - "integrity": "sha512-WMqqw06w1vUt9ZfT0gOFhMf3wHsWhaCrxGrckGs5Cci6ybDW87IvPaOd2pnBwT6BJuP/CzXDZxjFgmSULLdsdw==", - "license": "Apache-2.0", - "engines": { - "node": ">=20.19.0 || >=22.13.0 || >=24" - }, - "optionalDependencies": { - "@napi-rs/canvas": "^0.1.95", - "node-readable-to-web-readable-stream": "^0.4.2" - } - }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pino": { - "version": "9.14.0", - "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", - "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", - "license": "MIT", - "dependencies": { - "@pinojs/redact": "^0.4.0", - "atomic-sleep": "^1.0.0", - "on-exit-leak-free": "^2.1.0", - "pino-abstract-transport": "^2.0.0", - "pino-std-serializers": "^7.0.0", - "process-warning": "^5.0.0", - "quick-format-unescaped": "^4.0.3", - "real-require": "^0.2.0", - "safe-stable-stringify": "^2.3.1", - "sonic-boom": "^4.0.1", - "thread-stream": "^3.0.0" - }, "bin": { - "pino": "bin.js" - } - }, - "node_modules/pino-abstract-transport": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", - "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", - "license": "MIT", - "dependencies": { - "split2": "^4.0.0" + "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/pino-std-serializers": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", - "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, "license": "MIT" }, - "node_modules/playwright-core": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", - "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "argparse": "^2.0.1" }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss/node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "js-yaml": "bin/js-yaml.js" } }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/pretty-bytes": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", - "integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": "^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pretty-ms": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", - "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "parse-ms": "^4.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "license": "MIT" }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, "license": "MIT" }, - "node_modules/process-warning": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", - "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, "license": "MIT" }, - "node_modules/proper-lockfile": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", - "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "retry": "^0.12.0", - "signal-exit": "^3.0.2" - } + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" }, - "node_modules/proper-lockfile/node_modules/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/protobufjs": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", - "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", - "hasInstallScript": true, - "license": "BSD-3-Clause", "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/node": ">=13.7.0", - "long": "^5.0.0" + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" }, "engines": { - "node": ">=12.0.0" + "node": ">= 0.8.0" } }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" + "detect-libc": "^2.0.3" }, "engines": { - "node": ">= 0.10" - } - }, - "node_modules/proxy-addr/node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/proxy-agent": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", - "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "http-proxy-agent": "^7.0.1", - "https-proxy-agent": "^7.0.6", - "lru-cache": "^7.14.1", - "pac-proxy-agent": "^7.1.0", - "proxy-from-env": "^1.1.0", - "socks-proxy-agent": "^8.0.5" + "node": ">= 12.0.0" }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/proxy-agent/node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/proxy-agent/node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/proxy-agent/node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" } }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=6" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/punycode.js": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", - "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", - "license": "MIT", + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=6" - } - }, - "node_modules/qified": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/qified/-/qified-0.6.0.tgz", - "integrity": "sha512-tsSGN1x3h569ZSU1u6diwhltLyfUWDp3YbFHedapTmpBl0B3P6U3+Qptg7xu+v+1io1EwhdPyyRHYbEw0KN2FA==", - "license": "MIT", - "dependencies": { - "hookified": "^1.14.0" + "node": ">= 12.0.0" }, - "engines": { - "node": ">=20" - } - }, - "node_modules/qrcode-terminal": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz", - "integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==", - "bin": { - "qrcode-terminal": "bin/qrcode-terminal.js" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=0.6" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/quick-format-unescaped": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", - "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", - "license": "MIT" - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" + "node": ">= 12.0.0" }, - "engines": { - "node": ">= 0.10" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "peer": true, - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/readable-stream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/readdirp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", - "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", - "license": "MIT", + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 20.19.0" + "node": ">= 12.0.0" }, "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/real-require": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", - "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", - "license": "MIT", + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 12.13.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "license": "MIT", + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=0.10.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=0.10.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=8" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "license": "MIT", - "peer": true, - "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" - }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/restore-cursor/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "peer": true, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" }, - "node_modules/retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/rimraf": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", - "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", - "license": "ISC", "dependencies": { - "glob": "^10.3.7" + "p-locate": "^5.0.0" }, - "bin": { - "rimraf": "dist/esm/bin.mjs" + "engines": { + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/rimraf/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "dev": true, "license": "MIT" }, - "node_modules/rimraf/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } + "node_modules/lodash.kebabcase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", + "integrity": "sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==", + "dev": true, + "license": "MIT" }, - "node_modules/rimraf/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", + "dev": true, + "license": "MIT" }, - "node_modules/rimraf/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "license": "ISC" + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "dev": true, + "license": "MIT" }, - "node_modules/rimraf/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } + "node_modules/lodash.startcase": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz", + "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.upperfirst": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz", + "integrity": "sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==", + "dev": true, + "license": "MIT" }, - "node_modules/rimraf/node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": "20 || >=22" } }, - "node_modules/rolldown": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.10.tgz", - "integrity": "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==", + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.120.0", - "@rolldown/pluginutils": "1.0.0-rc.10" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.10", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", - "@rolldown/binding-darwin-x64": "1.0.0-rc.10", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "dev": true, "license": "MIT", "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safe-stable-stringify": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", - "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, "license": "MIT", "engines": { - "node": ">=10" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" + "node": ">= 0.4" } }, - "node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "node_modules/meow": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", + "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==", + "dev": true, "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, "engines": { - "node": ">= 18" + "node": ">=18" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "license": "MIT", + "node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" + "brace-expansion": "^5.0.2" }, "engines": { - "node": ">= 18" + "node": "18 || 20 || >=22" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/setimmediate": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", - "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", - "license": "MIT" - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@img/colour": "^1.0.0", - "detect-libc": "^2.1.2", - "semver": "^7.7.3" - }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" + "node": ">=16 || 14 >=14.17" } }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "dev": true, "license": "MIT", "dependencies": { - "shebang-regex": "^3.0.0" + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 0.8.0" } }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "yocto-queue": "^0.1.0" }, "engines": { - "node": ">= 0.4" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" + "p-limit": "^3.0.2" }, "engines": { - "node": ">= 0.4" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "inBundle": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" + "@types/retry": "0.12.0", + "retry": "^0.13.1" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=8" } }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "dev": true, - "license": "ISC" - }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC" + "license": "BlueOak-1.0.0" }, - "node_modules/simple-git": { - "version": "3.33.0", - "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.33.0.tgz", - "integrity": "sha512-D4V/tGC2sjsoNhoMybKyGoE+v8A60hRawKQ1iFRA1zwuDgGZCBJ4ByOzZ5J8joBbi4Oam0qiPH+GhzmSBwbJng==", + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@kwsites/file-exists": "^1.1.1", - "@kwsites/promise-deferred": "^1.1.1", - "debug": "^4.4.0" + "callsites": "^3.0.0" }, - "funding": { - "type": "github", - "url": "https://github.com/steveukx/git-js?sponsor=1" + "engines": { + "node": ">=6" } }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "license": "MIT" - }, - "node_modules/sleep-promise": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/sleep-promise/-/sleep-promise-9.1.0.tgz", - "integrity": "sha512-UHYzVpz9Xn8b+jikYSD6bqvf754xL2uBUzDFwiU6NcdZeifPr6UfgU43xpkPu67VMS88+TI2PSI7Eohgqf2fKA==", - "license": "MIT", - "peer": true - }, - "node_modules/slice-ansi": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", - "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "ansi-styles": "^6.2.3", - "is-fullwidth-code-point": "^5.1.0" + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" }, "engines": { - "node": ">=20" + "node": ">=8" }, "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "dev": true, "license": "MIT", "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks": { - "version": "2.8.7", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", - "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", - "license": "MIT", - "dependencies": { - "ip-address": "^10.0.1", - "smart-buffer": "^4.2.0" + "node": ">=18" }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/socks-proxy-agent": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", - "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, "engines": { - "node": ">= 14" + "node": ">=8" } }, - "node_modules/socks-proxy-agent/node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "license": "MIT", "engines": { - "node": ">= 14" + "node": ">=8" } }, - "node_modules/sonic-boom": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", - "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", - "license": "MIT", + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "atomic-sleep": "^1.0.0" - } - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "license": "BSD-3-Clause", + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, "engines": { - "node": ">=0.10.0" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } + "license": "MIT" }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" }, - "node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "license": "ISC", + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", "engines": { - "node": ">= 10.x" - } - }, - "node_modules/sqlite-vec": { - "version": "0.1.7-alpha.2", - "resolved": "https://registry.npmjs.org/sqlite-vec/-/sqlite-vec-0.1.7-alpha.2.tgz", - "integrity": "sha512-rNgRCv+4V4Ed3yc33Qr+nNmjhtrMnnHzXfLVPeGb28Dx5mmDL3Ngw/Wk8vhCGjj76+oC6gnkmMG8y73BZWGBwQ==", - "license": "MIT OR Apache", - "optionalDependencies": { - "sqlite-vec-darwin-arm64": "0.1.7-alpha.2", - "sqlite-vec-darwin-x64": "0.1.7-alpha.2", - "sqlite-vec-linux-arm64": "0.1.7-alpha.2", - "sqlite-vec-linux-x64": "0.1.7-alpha.2", - "sqlite-vec-windows-x64": "0.1.7-alpha.2" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/sqlite-vec-darwin-arm64": { - "version": "0.1.7-alpha.2", - "resolved": "https://registry.npmjs.org/sqlite-vec-darwin-arm64/-/sqlite-vec-darwin-arm64-0.1.7-alpha.2.tgz", - "integrity": "sha512-raIATOqFYkeCHhb/t3r7W7Cf2lVYdf4J3ogJ6GFc8PQEgHCPEsi+bYnm2JT84MzLfTlSTIdxr4/NKv+zF7oLPw==", - "cpu": [ - "arm64" - ], - "license": "MIT OR Apache", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/sqlite-vec-darwin-x64": { - "version": "0.1.7-alpha.2", - "resolved": "https://registry.npmjs.org/sqlite-vec-darwin-x64/-/sqlite-vec-darwin-x64-0.1.7-alpha.2.tgz", - "integrity": "sha512-jeZEELsQjjRsVojsvU5iKxOvkaVuE+JYC8Y4Ma8U45aAERrDYmqZoHvgSG7cg1PXL3bMlumFTAmHynf1y4pOzA==", - "cpu": [ - "x64" - ], - "license": "MIT OR Apache", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/sqlite-vec-linux-arm64": { - "version": "0.1.7-alpha.2", - "resolved": "https://registry.npmjs.org/sqlite-vec-linux-arm64/-/sqlite-vec-linux-arm64-0.1.7-alpha.2.tgz", - "integrity": "sha512-6Spj4Nfi7tG13jsUG+W7jnT0bCTWbyPImu2M8nWp20fNrd1SZ4g3CSlDAK8GBdavX7wRlbBHCZ+BDa++rbDewA==", - "cpu": [ - "arm64" - ], - "license": "MIT OR Apache", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/sqlite-vec-linux-x64": { - "version": "0.1.7-alpha.2", - "resolved": "https://registry.npmjs.org/sqlite-vec-linux-x64/-/sqlite-vec-linux-x64-0.1.7-alpha.2.tgz", - "integrity": "sha512-IcgrbHaDccTVhXDf8Orwdc2+hgDLAFORl6OBUhcvlmwswwBP1hqBTSEhovClG4NItwTOBNgpwOoQ7Qp3VDPWLg==", - "cpu": [ - "x64" - ], - "license": "MIT OR Apache", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/sqlite-vec-windows-x64": { - "version": "0.1.7-alpha.2", - "resolved": "https://registry.npmjs.org/sqlite-vec-windows-x64/-/sqlite-vec-windows-x64-0.1.7-alpha.2.tgz", - "integrity": "sha512-TRP6hTjAcwvQ6xpCZvjP00pdlda8J38ArFy1lMYhtQWXiIBmWnhMaMbq4kaeCYwvTTddfidatRS+TJrwIKB/oQ==", - "cpu": [ - "x64" + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } ], - "license": "MIT OR Apache", - "optional": true, - "os": [ - "win32" - ] + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "dev": true, - "license": "MIT" - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, "engines": { - "node": ">= 0.8" + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "license": "MIT" - }, - "node_modules/stdin-discarder": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.1.tgz", - "integrity": "sha512-reExS1kSGoElkextOcPkel4NE99S0BWxjUHQeDFnR8S993JxpPX7KU4MNmO19NXhlJp+8dmdCbKQVNgLJh2teA==", + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 0.8.0" } }, - "node_modules/stdout-update": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/stdout-update/-/stdout-update-4.0.1.tgz", - "integrity": "sha512-wiS21Jthlvl1to+oorePvcyrIkiG/6M3D3VTmDUlJm7Cy6SbFhKkAvX+YBuHLxck/tO3mrdpC/cNesigQc3+UQ==", + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "ansi-escapes": "^6.2.0", - "ansi-styles": "^6.2.1", - "string-width": "^7.1.0", - "strip-ansi": "^7.1.0" + "bin": { + "prettier": "bin/prettier.cjs" }, "engines": { - "node": ">=16.0.0" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/stdout-update/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "license": "MIT", - "peer": true - }, - "node_modules/stdout-update/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "node_modules/pretty-ms": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", + "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" + "parse-ms": "^4.0.0" }, "engines": { "node": ">=18" @@ -11793,227 +4057,191 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/steno": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/steno/-/steno-4.0.2.tgz", - "integrity": "sha512-yhPIQXjrlt1xv7dyPQg2P17URmXbuM5pdGkpiMB3RenprfiBlvK415Lctfe0eshk90oA7/tNq7WEiMK8RSP39A==", + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true, + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/typicode" + "node": ">=6" } }, - "node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" + "engines": { + "node": ">=0.10.0" } }, - "node_modules/string_decoder/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/string-width": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", - "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" - }, "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=0.10.0" } }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, "engines": { "node": ">=8" } }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, "license": "MIT", - "engines": { - "node": ">=8" + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, - "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "inBundle": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">= 4" } }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "node_modules/rolldown": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.10.tgz", + "integrity": "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==", + "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^5.0.1" + "@oxc-project/types": "=0.120.0", + "@rolldown/pluginutils": "1.0.0-rc.10" + }, + "bin": { + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=8" + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.10", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", + "@rolldown/binding-darwin-x64": "1.0.0-rc.10", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" } }, - "node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "node": ">=10" } }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^5.0.1" + "shebang-regex": "^3.0.0" }, "engines": { "node": ">=8" } }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "license": "MIT", - "peer": true, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, - "node_modules/strnum": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz", - "integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, "license": "MIT" }, - "node_modules/strtok3": { - "version": "10.3.4", - "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.4.tgz", - "integrity": "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==", + "node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "dev": true, "license": "MIT", - "dependencies": { - "@tokenizer/token": "^0.3.0" - }, "engines": { "node": ">=18" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/tar": { - "version": "7.5.11", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.11.tgz", - "integrity": "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "license": "MIT", "dependencies": { - "thenify": ">= 3.1.0 < 4" + "has-flag": "^4.0.0" }, "engines": { - "node": ">=0.8" - } - }, - "node_modules/thread-stream": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", - "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", - "license": "MIT", - "dependencies": { - "real-require": "^0.2.0" + "node": ">=8" } }, "node_modules/tinybench": { @@ -12060,80 +4288,32 @@ "node": ">=14.0.0" } }, - "node_modules/toad-cache": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", - "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/token-types": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", - "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", - "license": "MIT", - "dependencies": { - "@borewit/text-codec": "^0.2.1", - "@tokenizer/token": "^0.3.0", - "ieee754": "^1.2.1" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, - "node_modules/ts-algebra": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", - "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", - "license": "MIT" - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "dev": true, + "license": "0BSD", + "optional": true }, - "node_modules/tslog": { - "version": "4.10.2", - "resolved": "https://registry.npmjs.org/tslog/-/tslog-4.10.2.tgz", - "integrity": "sha512-XuELoRpMR+sq8fuWwX7P0bcj+PRNiicOKDEb3fGNURhxWVyykCi9BNq7c4uVz7h7P0sj8qgBsr5SWS6yBClq3g==", + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, "license": "MIT", - "engines": { - "node": ">=16" + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" }, - "funding": { - "url": "https://github.com/fullstack-build/tslog?sponsor=1" - } - }, - "node_modules/tsscmp": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", - "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", - "license": "MIT", "engines": { - "node": ">=0.6.x" + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" } }, "node_modules/tunnel": { @@ -12159,25 +4339,11 @@ "node": ">= 0.8.0" } }, - "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "license": "MIT", - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/typescript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -12187,76 +4353,24 @@ "node": ">=14.17" } }, - "node_modules/uc.micro": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", - "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", - "license": "MIT" - }, - "node_modules/uhyphen": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz", - "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==", - "license": "ISC" - }, - "node_modules/uint8array-extras": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", - "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/undici": { - "version": "7.24.3", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.3.tgz", - "integrity": "sha512-eJdUmK/Wrx2d+mnWWmwwLRyA7OQCkLap60sk3dOK4ViZR7DKwwptwuIvFBg2HaiP9ESaEdhtpSymQPvytpmkCA==", - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, "license": "MIT" }, - "node_modules/universal-github-app-jwt": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/universal-github-app-jwt/-/universal-github-app-jwt-2.2.2.tgz", - "integrity": "sha512-dcmbeSrOdTnsjGjUfAlqNDJrhxXizjAz94ija9Qw8YkZ1uu0d+GoZzyH+Jb9tIIqvGsadUfwg+22k5aDqqwzbw==", - "license": "MIT", - "peer": true - }, - "node_modules/universal-user-agent": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", - "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", - "license": "ISC", - "peer": true - }, - "node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, "license": "MIT", "engines": { - "node": ">= 0.8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/uri-js": { @@ -12269,38 +4383,6 @@ "punycode": "^2.1.0" } }, - "node_modules/url-join": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", - "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", - "license": "MIT", - "peer": true - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, - "node_modules/validate-npm-package-name": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", - "integrity": "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==", - "license": "ISC", - "peer": true, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/vite": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz", @@ -12479,47 +4561,6 @@ "defaults": "^1.0.3" } }, - "node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/which": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", - "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", - "license": "ISC", - "peer": true, - "dependencies": { - "isexe": "^4.0.0" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -12537,12 +4578,6 @@ "node": ">=8" } }, - "node_modules/win-guid": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/win-guid/-/win-guid-0.2.1.tgz", - "integrity": "sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==", - "license": "MIT" - }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -12557,6 +4592,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -12570,87 +4606,11 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/wrap-ansi/node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -12660,6 +4620,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -12675,6 +4636,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -12684,6 +4646,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -12698,6 +4661,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -12706,55 +4670,20 @@ "node": ">=8" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, - "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, "license": "ISC", "engines": { "node": ">=10" } }, - "node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "license": "ISC", "bin": { "yaml": "bin.mjs" @@ -12770,6 +4699,7 @@ "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -12788,6 +4718,7 @@ "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -12797,6 +4728,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -12806,6 +4738,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -12815,6 +4748,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -12829,6 +4763,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -12837,16 +4772,6 @@ "node": ">=8" } }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "license": "MIT", - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -12864,6 +4789,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -12871,24 +4797,6 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } - }, - "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-to-json-schema": { - "version": "3.25.1", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", - "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", - "license": "ISC", - "peerDependencies": { - "zod": "^3.25 || ^4" - } } } } diff --git a/package.json b/package.json index b8bf57b5a..a5ba9db5e 100644 --- a/package.json +++ b/package.json @@ -10,13 +10,21 @@ "test": "vitest run", "lint": "eslint .", "lint:fix": "eslint . --fix", + "format": "prettier --write 'bin/**/*.js' 'test/**/*.js'", + "format:check": "prettier --check 'bin/**/*.js' 'test/**/*.js'", "typecheck": "tsc -p jsconfig.json", - "prepare": "if [ -d .git ]; then if command -v prek >/dev/null 2>&1; then prek install; elif [ -d node_modules/@j178/prek ]; then echo \"ERROR: prek package found but binary not in PATH\" && exit 1; else echo \"Skipping git hook setup (prek not installed)\"; fi; fi", + "build:cli": "tsc -p tsconfig.src.json", + "typecheck:cli": "tsc -p tsconfig.cli.json", + "prepare": "if command -v tsc >/dev/null 2>&1 || [ -x node_modules/.bin/tsc ]; then npm run build:cli; fi && npm install --omit=dev --ignore-scripts 2>/dev/null || true && if [ -d .git ]; then if command -v prek >/dev/null 2>&1; then prek install; elif [ -d node_modules/@j178/prek ]; then echo \"ERROR: prek package found but binary not in PATH\" && exit 1; else echo \"Skipping git hook setup (prek not installed)\"; fi; fi", "prepublishOnly": "cd nemoclaw && env -u npm_config_global -u npm_config_prefix -u npm_config_omit npm install --ignore-scripts && ./node_modules/.bin/tsc" }, "dependencies": { - "openclaw": "2026.3.11" + "p-retry": "^4.6.2", + "yaml": "^2.8.3" }, + "bundleDependencies": [ + "p-retry" + ], "files": [ "bin/", "nemoclaw/dist/", @@ -28,7 +36,7 @@ ".dockerignore" ], "engines": { - "node": ">=22.0.0" + "node": ">=22.16.0" }, "repository": { "type": "git", @@ -42,6 +50,9 @@ "@types/node": "^25.5.0", "@vitest/coverage-v8": "^4.1.0", "eslint": "^10.1.0", + "execa": "^9.6.1", + "prettier": "^3.8.1", + "tsx": "^4.21.0", "typescript": "^6.0.2", "vitest": "^4.1.0" } diff --git a/pyproject.toml b/pyproject.toml index 57fcee9c9..7461a999f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ docs = [ "sphinxcontrib-mermaid", "nvidia-sphinx-theme", "sphinx-llm>=0.3.0", + "sphinx-reredirects", ] [tool.uv] diff --git a/scripts/brev-launchable-ci-cpu.sh b/scripts/brev-launchable-ci-cpu.sh new file mode 100755 index 000000000..2a1dace33 --- /dev/null +++ b/scripts/brev-launchable-ci-cpu.sh @@ -0,0 +1,251 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Brev launchable startup script — CI-Ready CPU +# +# Pre-bakes a VM with everything needed for NemoClaw E2E tests so that +# CI runs only need to: rsync branch code → npm ci → nemoclaw onboard → test. +# +# What this installs: +# 1. Docker (docker.io) — enabled and running +# 2. Node.js 22 (nodesource) +# 3. OpenShell CLI binary (pinned release) +# 4. NemoClaw repo cloned with npm deps installed and TS plugin built +# 5. Docker images pre-pulled (sandbox-base, openshell/cluster, node:22-slim) +# +# What this does NOT install (intentionally): +# - code-server (not needed for automated CI) +# - VS Code themes/extensions +# - NVIDIA Container Toolkit (see brev-launchable-ci-gpu.sh for GPU flavor) +# - Ollama / vLLM +# +# Readiness detection: +# Writes /var/run/nemoclaw-launchable-ready when complete. +# Also writes "=== Ready ===" to /tmp/launch-plugin.log for backward compat. +# +# Usage (Brev launchable startup script — one-liner that curls this): +# curl -fsSL https://raw.githubusercontent.com/NVIDIA/NemoClaw//scripts/brev-launchable-ci-cpu.sh | bash +# +# Environment overrides: +# OPENSHELL_VERSION — OpenShell CLI release tag (default: v0.0.20) +# NEMOCLAW_REF — NemoClaw git ref to clone (default: main) +# NEMOCLAW_CLONE_DIR — Where to clone NemoClaw (default: ~/NemoClaw) +# SKIP_DOCKER_PULL — Set to 1 to skip Docker image pre-pulls +# +# Related: +# - Epic: https://github.com/NVIDIA/NemoClaw/issues/1326 +# - Issue: https://github.com/NVIDIA/NemoClaw/issues/1327 + +set -euo pipefail + +# ── Configuration ──────────────────────────────────────────────────── +OPENSHELL_VERSION="${OPENSHELL_VERSION:-v0.0.20}" +NEMOCLAW_REF="${NEMOCLAW_REF:-main}" +TARGET_USER="${SUDO_USER:-$(id -un)}" +TARGET_HOME="$(getent passwd "$TARGET_USER" | cut -d: -f6)" +NEMOCLAW_CLONE_DIR="${NEMOCLAW_CLONE_DIR:-${TARGET_HOME}/NemoClaw}" + +LAUNCH_LOG="${LAUNCH_LOG:-/tmp/launch-plugin.log}" +SENTINEL="/var/run/nemoclaw-launchable-ready" + +# Docker images to pre-pull. These are the expensive layers that cause +# timeouts when pulled during CI runs. +DOCKER_IMAGES=( + "ghcr.io/nvidia/nemoclaw/sandbox-base:latest" + "node:22-slim" +) + +# ── Suppress apt noise ─────────────────────────────────────────────── +export DEBIAN_FRONTEND=noninteractive +export NEEDRESTART_MODE=a + +# ── Logging ────────────────────────────────────────────────────────── +mkdir -p "$(dirname "$LAUNCH_LOG")" +exec > >(tee -a "$LAUNCH_LOG") 2>&1 + +_ts() { date '+%H:%M:%S'; } +info() { printf '\033[0;32m[%s ci-cpu]\033[0m %s\n' "$(_ts)" "$1"; } +warn() { printf '\033[1;33m[%s ci-cpu]\033[0m %s\n' "$(_ts)" "$1"; } +fail() { + printf '\033[0;31m[%s ci-cpu]\033[0m %s\n' "$(_ts)" "$1" + exit 1 +} + +# ── Retry helper ───────────────────────────────────────────────────── +# Usage: retry 3 10 "description" command arg1 arg2 +retry() { + local max_attempts="$1" sleep_sec="$2" desc="$3" + shift 3 + local attempt=1 + while true; do + if "$@"; then + return 0 + fi + if ((attempt >= max_attempts)); then + warn "Failed after $max_attempts attempts: $desc" + return 1 + fi + info "Retry $attempt/$max_attempts for: $desc (sleeping ${sleep_sec}s)" + sleep "$sleep_sec" + ((attempt++)) + done +} + +# ── Wait for apt locks ─────────────────────────────────────────────── +# Brev VMs sometimes have unattended-upgrades running at boot. +wait_for_apt_lock() { + local max_wait=120 elapsed=0 + while fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1 \ + || fuser /var/lib/apt/lists/lock >/dev/null 2>&1; do + if ((elapsed >= max_wait)); then + warn "apt lock not released after ${max_wait}s — proceeding anyway" + return 0 + fi + if ((elapsed % 15 == 0)); then + info "Waiting for apt lock to be released... (${elapsed}s)" + fi + sleep 5 + ((elapsed += 5)) + done +} + +# ══════════════════════════════════════════════════════════════════════ +# 1. System packages +# ══════════════════════════════════════════════════════════════════════ +info "Installing system packages..." +wait_for_apt_lock +retry 3 10 "apt-get update" sudo apt-get update -qq +retry 3 10 "apt-get install" sudo apt-get install -y -qq \ + ca-certificates curl git jq tar >/dev/null 2>&1 +info "System packages installed" + +# ══════════════════════════════════════════════════════════════════════ +# 2. Docker +# ══════════════════════════════════════════════════════════════════════ +if command -v docker >/dev/null 2>&1; then + info "Docker already installed" +else + info "Installing Docker..." + wait_for_apt_lock + retry 3 10 "install docker" sudo apt-get install -y -qq docker.io >/dev/null 2>&1 + info "Docker installed" +fi +sudo systemctl enable --now docker +sudo usermod -aG docker "$TARGET_USER" 2>/dev/null || true +# Make the socket world-accessible so SSH sessions (which don't pick up the +# new docker group until re-login) can use Docker immediately. This is a +# short-lived CI VM — socket security is not a concern. +sudo chmod 666 /var/run/docker.sock +info "Docker enabled ($(docker --version 2>/dev/null | head -c 40))" + +# ══════════════════════════════════════════════════════════════════════ +# 3. Node.js 22 +# ══════════════════════════════════════════════════════════════════════ +node_major="" +if command -v node >/dev/null 2>&1; then + node_major="$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || true)" +fi + +if command -v npm >/dev/null 2>&1 && [[ -n "$node_major" ]] && ((node_major >= 22)); then + info "Node.js already installed: $(node --version)" +else + info "Installing Node.js 22..." + curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - >/dev/null 2>&1 + wait_for_apt_lock + retry 3 10 "install nodejs" sudo apt-get install -y -qq nodejs >/dev/null 2>&1 + info "Node.js $(node --version) installed" +fi + +# ══════════════════════════════════════════════════════════════════════ +# 4. OpenShell CLI +# ══════════════════════════════════════════════════════════════════════ +if command -v openshell >/dev/null 2>&1; then + info "OpenShell CLI already installed: $(openshell --version 2>&1 || echo unknown)" +else + info "Installing OpenShell CLI ${OPENSHELL_VERSION}..." + ARCH="$(uname -m)" + case "$ARCH" in + x86_64 | amd64) ASSET="openshell-x86_64-unknown-linux-musl.tar.gz" ;; + aarch64 | arm64) ASSET="openshell-aarch64-unknown-linux-musl.tar.gz" ;; + *) fail "Unsupported architecture: $ARCH" ;; + esac + tmpdir="$(mktemp -d)" + retry 3 10 "download openshell" \ + curl -fsSL -o "$tmpdir/$ASSET" \ + "https://github.com/NVIDIA/OpenShell/releases/download/${OPENSHELL_VERSION}/${ASSET}" + tar xzf "$tmpdir/$ASSET" -C "$tmpdir" + sudo install -m 755 "$tmpdir/openshell" /usr/local/bin/openshell + rm -rf "$tmpdir" + info "OpenShell CLI installed: $(openshell --version 2>&1 || echo unknown)" +fi + +# ══════════════════════════════════════════════════════════════════════ +# 5. Clone NemoClaw and install deps +# ══════════════════════════════════════════════════════════════════════ +if [[ -d "$NEMOCLAW_CLONE_DIR/.git" ]]; then + info "NemoClaw repo exists at $NEMOCLAW_CLONE_DIR — refreshing" + git -C "$NEMOCLAW_CLONE_DIR" fetch origin "$NEMOCLAW_REF" + git -C "$NEMOCLAW_CLONE_DIR" checkout "$NEMOCLAW_REF" + git -C "$NEMOCLAW_CLONE_DIR" pull --ff-only origin "$NEMOCLAW_REF" || true +else + info "Cloning NemoClaw (ref: $NEMOCLAW_REF)..." + git clone --branch "$NEMOCLAW_REF" --depth 1 \ + "https://github.com/NVIDIA/NemoClaw.git" "$NEMOCLAW_CLONE_DIR" +fi + +info "Installing npm dependencies..." +cd "$NEMOCLAW_CLONE_DIR" +npm install --ignore-scripts 2>&1 | tail -3 +info "Root deps installed" + +info "Building TypeScript plugin..." +cd "$NEMOCLAW_CLONE_DIR/nemoclaw" +npm install 2>&1 | tail -3 +npm run build 2>&1 | tail -3 +cd "$NEMOCLAW_CLONE_DIR" +info "Plugin built" + +# ══════════════════════════════════════════════════════════════════════ +# 6. Pre-pull Docker images +# ══════════════════════════════════════════════════════════════════════ +if [[ "${SKIP_DOCKER_PULL:-0}" == "1" ]]; then + info "Skipping Docker image pre-pulls (SKIP_DOCKER_PULL=1)" +else + info "Pre-pulling Docker images (this saves 3-5 min per CI run)..." + + # Use sg docker to ensure docker group is active without re-login + for image in "${DOCKER_IMAGES[@]}"; do + info " Pulling $image..." + sg docker -c "docker pull $image" 2>&1 | tail -1 \ + || warn " Failed to pull $image (will be pulled at test time)" + done + + # The openshell/cluster image tag should match the CLI version. + # Try the pinned version first, fall back to latest. + CLUSTER_TAG="${OPENSHELL_VERSION#v}" # v0.0.20 → 0.0.20 + CLUSTER_IMAGE="ghcr.io/nvidia/openshell/cluster:${CLUSTER_TAG}" + info " Pulling $CLUSTER_IMAGE..." + if ! sg docker -c "docker pull $CLUSTER_IMAGE" 2>&1 | tail -1; then + warn " Could not pull $CLUSTER_IMAGE — trying :latest" + sg docker -c "docker pull ghcr.io/nvidia/openshell/cluster:latest" 2>&1 | tail -1 \ + || warn " Failed to pull openshell/cluster (will be pulled at test time)" + fi + + info "Docker images pre-pulled" +fi + +# ══════════════════════════════════════════════════════════════════════ +# 7. Readiness sentinel +# ══════════════════════════════════════════════════════════════════════ +sudo touch "$SENTINEL" +echo "=== Ready ===" | sudo tee -a "$LAUNCH_LOG" >/dev/null + +info "════════════════════════════════════════════════════" +info " CI-Ready CPU launchable setup complete" +info " NemoClaw: $NEMOCLAW_CLONE_DIR (ref: $NEMOCLAW_REF)" +info " OpenShell: $(openshell --version 2>&1 || echo unknown)" +info " Node.js: $(node --version)" +info " Docker: $(docker --version 2>/dev/null | head -c 40)" +info " Sentinel: $SENTINEL" +info "════════════════════════════════════════════════════" diff --git a/scripts/brev-setup.sh b/scripts/brev-setup.sh index cc8701ba9..29924d1a6 100755 --- a/scripts/brev-setup.sh +++ b/scripts/brev-setup.sh @@ -2,7 +2,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # -# Brev VM bootstrap — installs prerequisites then runs setup.sh. +# Brev VM bootstrap — installs prerequisites then runs nemoclaw onboard. # # Run on a fresh Brev VM: # export NVIDIA_API_KEY=nvapi-... @@ -12,7 +12,7 @@ # 1. Installs Docker (if missing) # 2. Installs NVIDIA Container Toolkit (if GPU present) # 3. Installs openshell CLI from GitHub release (binary, no Rust build) -# 4. Runs setup.sh +# 4. Installs nemoclaw CLI and runs nemoclaw onboard set -euo pipefail @@ -21,10 +21,11 @@ GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' -info() { echo -e "${GREEN}[brev]${NC} $1"; } -warn() { echo -e "${YELLOW}[brev]${NC} $1"; } +_ts() { date '+%H:%M:%S'; } +info() { echo -e "${GREEN}[$(_ts) brev]${NC} $1"; } +warn() { echo -e "${YELLOW}[$(_ts) brev]${NC} $1"; } fail() { - echo -e "${RED}[brev]${NC} $1" + echo -e "${RED}[$(_ts) brev]${NC} $1" exit 1 } @@ -39,7 +40,23 @@ export DEBIAN_FRONTEND=noninteractive # --- 0. Node.js (needed for services) --- if ! command -v node >/dev/null 2>&1; then info "Installing Node.js..." - curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - >/dev/null 2>&1 + NODESOURCE_URL="https://deb.nodesource.com/setup_22.x" + NODESOURCE_SHA256="575583bbac2fccc0b5edd0dbc03e222d9f9dc8d724da996d22754d6411104fd1" + ( + tmpdir="$(mktemp -d)" + trap 'rm -rf "$tmpdir"' EXIT + curl -fsSL "$NODESOURCE_URL" -o "$tmpdir/setup_node.sh" + if command -v sha256sum >/dev/null 2>&1; then + echo "$NODESOURCE_SHA256 $tmpdir/setup_node.sh" | sha256sum -c - >/dev/null \ + || fail "NodeSource installer checksum mismatch — expected $NODESOURCE_SHA256" + elif command -v shasum >/dev/null 2>&1; then + echo "$NODESOURCE_SHA256 $tmpdir/setup_node.sh" | shasum -a 256 -c - >/dev/null \ + || fail "NodeSource installer checksum mismatch — expected $NODESOURCE_SHA256" + else + fail "No SHA-256 verification tool found (need sha256sum or shasum)" + fi + sudo -E bash "$tmpdir/setup_node.sh" >/dev/null 2>&1 + ) sudo apt-get install -y -qq nodejs >/dev/null 2>&1 info "Node.js $(node --version) installed" else @@ -120,7 +137,9 @@ fi # --- 4. vLLM (local inference, if GPU present) --- VLLM_MODEL="nvidia/nemotron-3-nano-30b-a3b" -if command -v nvidia-smi >/dev/null 2>&1; then +if [ "${SKIP_VLLM:-}" = "1" ]; then + info "Skipping vLLM install (SKIP_VLLM=1)" +elif command -v nvidia-smi >/dev/null 2>&1; then if ! python3 -c "import vllm" 2>/dev/null; then info "Installing vLLM..." if ! command -v pip3 >/dev/null 2>&1; then @@ -158,9 +177,33 @@ if command -v nvidia-smi >/dev/null 2>&1; then fi fi -# --- 5. Run setup.sh --- +# --- 5. Install nemoclaw CLI and run onboard --- +REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +info "Installing nemoclaw CLI..." +export npm_config_prefix="$HOME/.local" +export PATH="$HOME/.local/bin:$PATH" +(cd "$REPO_DIR/nemoclaw" && npm install && npm run build) >/dev/null 2>&1 +(cd "$REPO_DIR" && npm install --ignore-scripts && npm link) >/dev/null 2>&1 +info "nemoclaw $(nemoclaw --version) installed" + # Use sg docker to ensure docker group is active (usermod -aG doesn't # take effect in the current session without re-login) -info "Running setup.sh..." + +# CHAT_UI_URL tells onboard which browser origin to allow in the gateway +# config. On Brev, the launchable config should set this to the public URL +# (e.g. https://openclaw0-.brevlab.com). Without it the dashboard +# rejects remote browsers with "origin not allowed". +# Ref: https://github.com/NVIDIA/NemoClaw/issues/795 +if [ -n "${CHAT_UI_URL:-}" ]; then + export CHAT_UI_URL + info "CHAT_UI_URL=${CHAT_UI_URL}" +elif [ -z "${DISPLAY:-}" ] && [ ! -e /tmp/.X11-unix ]; then + warn "CHAT_UI_URL is not set. Remote browser access will fail with" + warn "'origin not allowed' unless you set CHAT_UI_URL to the public URL" + warn "of this instance (e.g. https://openclaw0-.brevlab.com)." +fi + +info "Running nemoclaw onboard..." export NVIDIA_API_KEY -exec sg docker -c "bash $SCRIPT_DIR/setup.sh" +exec sg docker -c "nemoclaw onboard --non-interactive" diff --git a/scripts/check-coverage-ratchet.sh b/scripts/check-coverage-ratchet.sh deleted file mode 100755 index d97fa3c37..000000000 --- a/scripts/check-coverage-ratchet.sh +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env bash -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# -# Compares Vitest coverage output against ci/coverage-threshold.json. -# Fails if any metric drops below the threshold (with 1% tolerance). -# Prints updated thresholds when coverage improves, so contributors -# can update the file and ratchet the floor upward. - -set -euo pipefail - -REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -THRESHOLD_FILE="$REPO_ROOT/ci/coverage-threshold.json" -SUMMARY_FILE="$REPO_ROOT/coverage/coverage-summary.json" - -if [ ! -f "$THRESHOLD_FILE" ]; then - echo "ERROR: Threshold file not found: $THRESHOLD_FILE" - exit 1 -fi - -if [ ! -f "$SUMMARY_FILE" ]; then - echo "ERROR: Coverage summary not found: $SUMMARY_FILE" - echo "Run 'npx vitest run --coverage' first." - exit 1 -fi - -# Single Python invocation handles all parsing, comparison, and output. -python3 - "$SUMMARY_FILE" "$THRESHOLD_FILE" <<'PY' -import json, math, sys - -summary_path, threshold_path = sys.argv[1], sys.argv[2] -try: - with open(summary_path) as f: - summary = json.load(f)["total"] - with open(threshold_path) as f: - thresholds = json.load(f) -except (json.JSONDecodeError, KeyError) as e: - print(f"ERROR: Failed to parse coverage files: {e}") - sys.exit(1) - -TOLERANCE = 1 -METRICS = ["lines", "functions", "branches", "statements"] - -failed = False -improved = False - -print("=== Coverage Ratchet Check ===") -print() - -for metric in METRICS: - actual = summary[metric]["pct"] - threshold = thresholds[metric] - - if actual < threshold - TOLERANCE: - print(f"FAIL: {metric} coverage is {actual}%, threshold is {threshold}% (tolerance {TOLERANCE}%)") - failed = True - elif actual > threshold + TOLERANCE: - print(f"IMPROVED: {metric} coverage is {actual}%, above threshold {threshold}%") - improved = True - else: - print(f"OK: {metric} coverage is {actual}% (threshold {threshold}%)") - -print() - -if failed: - print("Coverage regression detected. Add tests to bring coverage back above the threshold.") - sys.exit(1) - -if improved: - new = {} - for metric in METRICS: - new[metric] = max(math.floor(summary[metric]["pct"]), thresholds[metric]) - new_json = json.dumps(new, indent=2) - print("Coverage improved! Update ci/coverage-threshold.json to ratchet the floor:") - print() - print(new_json) - print() - print(f"Run: echo '{new_json}' > ci/coverage-threshold.json") - -print("Coverage ratchet passed.") -PY diff --git a/scripts/check-coverage-ratchet.ts b/scripts/check-coverage-ratchet.ts new file mode 100755 index 000000000..2d4a5a85f --- /dev/null +++ b/scripts/check-coverage-ratchet.ts @@ -0,0 +1,57 @@ +#!/usr/bin/env -S npx tsx +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Compares a Vitest coverage summary against a threshold file. +// Exits non-zero if any metric drops more than 1% below its threshold. + +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const METRICS = ["lines", "functions", "branches", "statements"] as const; + +type MetricName = (typeof METRICS)[number]; + +type Thresholds = Record; + +const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const TOLERANCE = 1; + +/** Read and JSON-parse a repo-relative file. */ +function loadJSON(repoRelative: string): T { + const abs = join(REPO_ROOT, repoRelative); + try { + return JSON.parse(readFileSync(abs, "utf-8")) as T; + } catch (cause) { + throw new Error(`Failed to load ${abs}`, { cause }); + } +} + +function main(): void { + const [summaryPath, thresholdPath, label = "coverage"] = process.argv.slice(2); + if (!summaryPath || !thresholdPath) { + throw new Error( + "Usage: check-coverage-ratchet.ts [label]" + ); + } + const summary = loadJSON<{ total: Record }>(summaryPath); + const thresholds = loadJSON(thresholdPath); + + const failures = METRICS.map((metric) => ({ + metric, + actual: summary.total[metric].pct, + threshold: thresholds[metric], + })).filter((r) => r.actual < r.threshold - TOLERANCE); + + if (failures.length === 0) return; + + console.error(`${label} ratchet failed:\n`); + for (const { metric, actual, threshold } of failures) { + console.error(` ${metric}: ${actual}% < ${threshold}% (tolerance ±${TOLERANCE}%)`); + } + console.error("\nAdd tests to bring coverage back above the threshold."); + process.exitCode = 1; +} + +main(); diff --git a/scripts/check-version-tag-sync.sh b/scripts/check-version-tag-sync.sh new file mode 100755 index 000000000..af1a1d669 --- /dev/null +++ b/scripts/check-version-tag-sync.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Pre-push hook: when pushing a v* tag, verify that package.json at the +# tagged commit has a matching version. Blocks the push if they differ. +# +# Usage (called by prek as a pre-push hook): +# echo " " | bash scripts/check-version-tag-sync.sh +# +# Manual check (no stdin needed — compares latest v* tag with package.json): +# bash scripts/check-version-tag-sync.sh --check + +set -euo pipefail + +RED=$'\033[1;31m' +GREEN=$'\033[32m' +DIM=$'\033[2m' +RESET=$'\033[0m' + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# Extract the "version" field from the package.json at a given commit. +version_at_commit() { + local sha="$1" + git -C "$ROOT" show "${sha}:package.json" 2>/dev/null \ + | sed -nE 's/^[[:space:]]*"version":[[:space:]]*"([^"]+)".*/\1/p' \ + | head -1 +} + +check_tag() { + local tag="$1" sha="$2" + local tag_version="${tag#v}" + local pkg_version + pkg_version="$(version_at_commit "$sha")" + + if [[ -z "$pkg_version" ]]; then + echo "${RED}✗${RESET} Tag ${tag}: could not read package.json at ${sha:0:8}" >&2 + return 1 + fi + + if [[ "$pkg_version" != "$tag_version" ]]; then + cat >&2 </dev/null || true)" + if [[ -z "$latest_tag" ]]; then + echo "${DIM}No v* tags found — nothing to check.${RESET}" + exit 0 + fi + sha="$(git -C "$ROOT" rev-list -1 "$latest_tag")" + check_tag "$latest_tag" "$sha" + exit $? +fi + +# ------------------------------------------------------------------ +# Pre-push mode: read pushed refs from stdin +# ------------------------------------------------------------------ +errors=0 + +while IFS=' ' read -r local_ref local_sha _remote_ref _remote_sha; do + # Only care about v* tag pushes + case "$local_ref" in + refs/tags/v*) + tag="${local_ref#refs/tags/}" + check_tag "$tag" "$local_sha" || errors=$((errors + 1)) + ;; + esac +done + +if ((errors > 0)); then + exit 1 +fi diff --git a/scripts/clean-staged-tree.sh b/scripts/clean-staged-tree.sh new file mode 100755 index 000000000..93a550e21 --- /dev/null +++ b/scripts/clean-staged-tree.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +target_dir="${1:-}" + +if [ -z "$target_dir" ]; then + echo "usage: $0 " >&2 + exit 1 +fi + +rm -rf "$target_dir/.venv" "$target_dir/.pytest_cache" +find "$target_dir" -type d -name __pycache__ -prune -exec rm -rf {} + 2>/dev/null || true diff --git a/scripts/debug.sh b/scripts/debug.sh index 045f38fc9..2426d4287 100755 --- a/scripts/debug.sh +++ b/scripts/debug.sh @@ -107,6 +107,16 @@ elif command -v gtimeout >/dev/null 2>&1; then TIMEOUT_BIN="gtimeout" fi +SCRIPT_DIR="" +REPO_ROOT="" +ONBOARD_SESSION_HELPER="" +SCRIPT_PATH="${BASH_SOURCE[0]:-}" +if [ -n "$SCRIPT_PATH" ] && [ -f "$SCRIPT_PATH" ]; then + SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)" + REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + ONBOARD_SESSION_HELPER="${REPO_ROOT}/bin/lib/onboard-session.js" +fi + # Redact known sensitive patterns (API keys, tokens, passwords in env/args). redact() { sed -E \ @@ -243,6 +253,24 @@ if [ "$QUICK" = false ]; then collect "openshell-gateway-info" openshell gateway info fi +# -- Onboard session state -- + +section "Onboard Session" +if [ -n "$ONBOARD_SESSION_HELPER" ] && [ -f "$ONBOARD_SESSION_HELPER" ] && command -v node >/dev/null 2>&1; then + # shellcheck disable=SC2016 + collect "onboard-session-summary" node -e ' + const helper = require(process.argv[1]); + const summary = helper.summarizeForDebug(); + if (!summary) { + process.stdout.write("No onboard session state found.\n"); + process.exit(0); + } + process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`); + ' "$ONBOARD_SESSION_HELPER" +else + echo " (onboard session helper not available, skipping)" +fi + # -- Sandbox internals (via SSH using openshell ssh-config) -- if command -v openshell &>/dev/null \ diff --git a/scripts/docs-to-skills.py b/scripts/docs-to-skills.py index 4425a5712..a9d3e6d55 100755 --- a/scripts/docs-to-skills.py +++ b/scripts/docs-to-skills.py @@ -119,6 +119,7 @@ class DocPage: # Derived fields populated after parsing title: str = "" description: str = "" + description_is_agent: bool = False content_type: str = "" # concept, how_to, reference, get_started, tutorial difficulty: str = "" keywords: list[str] = field(default_factory=list) @@ -223,7 +224,17 @@ def parse_doc(path: Path) -> DocPage: elif isinstance(title_block, str): page.title = title_block - page.description = fm.get("description", "") + desc = fm.get("description", "") + if isinstance(desc, dict): + main = str(desc.get("main") or "").strip() + agent = str(desc.get("agent") or "").strip() + if agent: + page.description = agent + page.description_is_agent = True + else: + page.description = main + else: + page.description = str(desc or "").strip() page.keywords = fm.get("keywords", []) page.tags = fm.get("tags", []) @@ -541,47 +552,6 @@ def _safe_truncation_point(lines: list[str], target: int) -> int: return last_safe -def extract_trigger_keywords(pages: list[DocPage]) -> list[str]: - """Build trigger keywords from doc metadata across a group of pages.""" - keywords: set[str] = set() - - for page in pages: - keywords.update(page.keywords) - for tag in page.tags: - keywords.add(tag.replace("_", " ")) - - # Extract meaningful words from the title - if page.title: - title_words = re.sub(r"[^a-zA-Z\s]", "", page.title).lower().split() - stop_words = { - "the", - "a", - "an", - "and", - "or", - "for", - "to", - "in", - "of", - "it", - "how", - "what", - "with", - "from", - "by", - "on", - "is", - } - title_words = [w for w in title_words if w not in stop_words and len(w) > 2] - if len(title_words) >= 2: - keywords.add(" ".join(title_words[:4])) - - # Remove duplicates of the skill name itself and generic terms - generic = {"generative_ai", "generative ai", "ai_agents", "ai agents", "published"} - keywords -= generic - return sorted(keywords)[:15] # Cap at 15 keywords - - TITLE_VERBS = { "customize": "manage", "approve": "manage", @@ -792,36 +762,43 @@ def generate_skill_name( return name -def build_skill_description( - name: str, pages: list[DocPage], keywords: list[str] -) -> str: +BRAND_WORDS: dict[str, str] = { + "nemoclaw": "NemoClaw", + "openclaw": "OpenClaw", + "openshell": "OpenShell", + "nvidia": "NVIDIA", + "gpu": "GPU", + "cli": "CLI", + "tui": "TUI", + "api": "API", + "llm": "LLM", + "llms": "LLMs", +} + + +def _brand_case(text: str) -> str: + """Replace generic title-cased words with their brand-correct forms.""" + for wrong, right in BRAND_WORDS.items(): + text = re.sub(rf"\b{re.escape(wrong)}\b", right, text, flags=re.IGNORECASE) + return text + + +def build_skill_description(name: str, pages: list[DocPage]) -> str: """Build the description field for the skill frontmatter. - Best-practices compliance: - - Uses third-person voice (e.g. "Installs..." not "Install...") - - Includes "Use when..." clause instead of flat "Trigger keywords -" list - - Keeps description under 1024 characters + When a page supplies ``description.agent``, its text is used verbatim. + Legacy flat descriptions are still converted to third-person voice. + Keeps description under 1024 characters. """ - descriptions = [p.description for p in pages if p.description] + descriptions = [ + d if is_agent else _to_third_person(d) + for d, is_agent in ((p.description, p.description_is_agent) for p in pages if p.description) + ] if descriptions: - combined = _to_third_person(descriptions[0]).rstrip(".") - if len(descriptions) > 1: - extras = [] - for d in descriptions[1:3]: - clean = _to_third_person(d).rstrip(".") - if clean: - clean = clean[0].lower() + clean[1:] - extras.append(clean) - combined += ". Also covers " + "; ".join(extras) + "." - else: - combined += "." + combined = " ".join(d.rstrip().rstrip(".") + "." for d in descriptions) else: combined = f"Documentation-derived skill for {name.replace('-', ' ')}." - kw_list = keywords[:8] - if kw_list: - combined += " Use when " + ", ".join(kw_list) + "." - if len(combined) > 1024: combined = combined[:1020] + "..." return combined @@ -904,8 +881,7 @@ def generate_skill( Writes identical output to each directory in *output_dirs*. Returns a summary dict for reporting. """ - keywords = extract_trigger_keywords(pages) - description = build_skill_description(name, pages, keywords) + description = build_skill_description(name, pages) def _clean(text: str, source: DocPage) -> str: """Apply directive cleanup and path rewriting for a source page.""" @@ -939,7 +915,7 @@ def _clean(text: str, source: DocPage) -> str: lines.append("") # Title - skill_title = name.replace("-", " ").title() + skill_title = _brand_case(name.replace("-", " ").title()) lines.append(f"# {skill_title}") lines.append("") @@ -1031,7 +1007,7 @@ def _clean(text: str, source: DocPage) -> str: lines.append("") for rp in reference_pages: ref_name = rp.path.stem + ".md" - title = rp.title or rp.path.stem.replace("-", " ").title() + title = rp.title or _brand_case(rp.path.stem.replace("-", " ").title()) lines.append(f"- [{title}](references/{ref_name})") lines.append("") diff --git a/scripts/fix-coredns.sh b/scripts/fix-coredns.sh index 9b587ab33..1c7c6b888 100755 --- a/scripts/fix-coredns.sh +++ b/scripts/fix-coredns.sh @@ -2,17 +2,18 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # -# Fix CoreDNS on local OpenShell gateways running under Colima. +# Fix CoreDNS on local OpenShell gateways. # # Problem: k3s CoreDNS forwards to /etc/resolv.conf which inside the -# CoreDNS pod resolves to 127.0.0.11 (Docker's embedded DNS). That -# address is NOT reachable from k3s pods, causing DNS to fail and -# CoreDNS to CrashLoop. +# CoreDNS pod resolves to a loopback address (127.0.0.11 on Docker, +# 127.0.0.53 on systemd-resolved hosts). That address is NOT reachable +# from k3s pods, causing DNS to fail and CoreDNS to CrashLoop. # -# Fix: forward CoreDNS to the container's default gateway IP, which -# is reachable from pods and routes DNS through Docker to the host. +# Fix: forward CoreDNS to a real upstream DNS server, discovered from +# the container's resolv.conf, the host's resolv.conf, or +# systemd-resolved's actual upstream. # -# Run this after `openshell gateway start` on Colima setups. +# Run this after `openshell gateway start` on any Docker-based setup. # # Usage: ./scripts/fix-coredns.sh [gateway-name] @@ -23,14 +24,9 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" # shellcheck source=./lib/runtime.sh . "$SCRIPT_DIR/lib/runtime.sh" -COLIMA_SOCKET="$(find_colima_docker_socket || true)" - if [ -z "${DOCKER_HOST:-}" ]; then - if [ -n "$COLIMA_SOCKET" ]; then - export DOCKER_HOST="unix://$COLIMA_SOCKET" - else - echo "Skipping CoreDNS patch: Colima socket not found." - exit 0 + if docker_host="$(detect_docker_host)"; then + export DOCKER_HOST="$docker_host" fi fi @@ -48,11 +44,25 @@ fi CONTAINER_RESOLV_CONF="$(docker exec "$CLUSTER" cat /etc/resolv.conf 2>/dev/null || true)" HOST_RESOLV_CONF="$(cat /etc/resolv.conf 2>/dev/null || true)" -UPSTREAM_DNS="$(resolve_coredns_upstream "$CONTAINER_RESOLV_CONF" "$HOST_RESOLV_CONF" "colima" || true)" + +# Detect the container runtime so resolve_coredns_upstream can use +# runtime-specific fallbacks (e.g. Colima VM nameserver). +RUNTIME="unknown" +if command -v colima >/dev/null 2>&1 && [[ "${DOCKER_HOST:-}" == *colima* ]]; then + RUNTIME="colima" +fi +UPSTREAM_DNS="$(resolve_coredns_upstream "$CONTAINER_RESOLV_CONF" "$HOST_RESOLV_CONF" "$RUNTIME" || true)" + +# If all resolv.conf sources returned loopback only (common on systemd-resolved +# hosts where /etc/resolv.conf is 127.0.0.53), try resolvectl for real upstreams. +if [ -z "$UPSTREAM_DNS" ] && command -v resolvectl >/dev/null 2>&1; then + UPSTREAM_DNS="$(resolvectl status 2>/dev/null \ + | awk '/Current DNS Server:/ { print $NF; exit }')" +fi if [ -z "$UPSTREAM_DNS" ]; then - echo "ERROR: Could not determine a non-loopback DNS upstream for Colima." - exit 1 + echo "WARNING: Could not determine a non-loopback DNS upstream. Falling back to 8.8.8.8." + UPSTREAM_DNS="8.8.8.8" fi echo "Patching CoreDNS to forward to $UPSTREAM_DNS..." diff --git a/scripts/install-openshell.sh b/scripts/install-openshell.sh index 2cd6934cc..b0a0baaac 100755 --- a/scripts/install-openshell.sh +++ b/scripts/install-openshell.sh @@ -79,14 +79,33 @@ esac tmpdir="$(mktemp -d)" trap 'rm -rf "$tmpdir"' EXIT -if command -v gh >/dev/null 2>&1; then - GH_TOKEN="${GITHUB_TOKEN:-}" gh release download --repo NVIDIA/OpenShell \ - --pattern "$ASSET" --dir "$tmpdir" -else +CHECKSUM_FILE="openshell-checksums-sha256.txt" +download_with_curl() { curl -fsSL "https://github.com/NVIDIA/OpenShell/releases/latest/download/$ASSET" \ -o "$tmpdir/$ASSET" + curl -fsSL "https://github.com/NVIDIA/OpenShell/releases/latest/download/$CHECKSUM_FILE" \ + -o "$tmpdir/$CHECKSUM_FILE" +} + +if command -v gh >/dev/null 2>&1; then + if GH_PROMPT_DISABLED=1 GH_TOKEN="${GH_TOKEN:-${GITHUB_TOKEN:-}}" gh release download --repo NVIDIA/OpenShell \ + --pattern "$ASSET" --dir "$tmpdir" 2>/dev/null \ + && GH_PROMPT_DISABLED=1 GH_TOKEN="${GH_TOKEN:-${GITHUB_TOKEN:-}}" gh release download --repo NVIDIA/OpenShell \ + --pattern "$CHECKSUM_FILE" --dir "$tmpdir" 2>/dev/null; then + : # gh succeeded + else + warn "gh CLI download failed (auth may not be configured) — falling back to curl" + rm -f "$tmpdir/$ASSET" "$tmpdir/$CHECKSUM_FILE" + download_with_curl + fi +else + download_with_curl fi +info "Verifying SHA-256 checksum..." +(cd "$tmpdir" && grep -F "$ASSET" "$CHECKSUM_FILE" | shasum -a 256 -c -) \ + || fail "SHA-256 checksum verification failed for $ASSET" + tar xzf "$tmpdir/$ASSET" -C "$tmpdir" target_dir="/usr/local/bin" diff --git a/scripts/install.sh b/scripts/install.sh index b00de5e86..c448afa1b 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2,379 +2,758 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # -# NemoClaw curl-pipe-bash installer. +# NEMOCLAW_VERSIONED_INSTALLER_PAYLOAD=1 # -# Usage: -# curl -fsSL https://raw.githubusercontent.com/NVIDIA/NemoClaw/main/scripts/install.sh | bash +# NemoClaw installer — installs Node.js, Ollama (if GPU present), and NemoClaw. set -euo pipefail -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' - -info() { echo -e "${GREEN}[install]${NC} $1"; } -warn() { echo -e "${YELLOW}[install]${NC} $1"; } -fail() { - echo -e "${RED}[install]${NC} $1" - exit 1 +# Global cleanup state — ensures background processes are killed and temp files +# are removed on any exit path (set -e, unhandled signal, unexpected error). +_cleanup_pids=() +_cleanup_files=() +_global_cleanup() { + for pid in "${_cleanup_pids[@]:-}"; do + kill "$pid" 2>/dev/null || true + done + for f in "${_cleanup_files[@]:-}"; do + rm -f "$f" 2>/dev/null || true + done } +trap _global_cleanup EXIT -define_runtime_helpers() { - socket_exists() { - local socket_path="$1" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" - if [ -n "${NEMOCLAW_TEST_SOCKET_PATHS:-}" ]; then - case ":$NEMOCLAW_TEST_SOCKET_PATHS:" in - *":$socket_path:"*) return 0 ;; - esac +resolve_repo_root() { + local base="${NEMOCLAW_REPO_ROOT:-$SCRIPT_DIR}" + if [[ -f "${base}/package.json" ]]; then + (cd "${base}" && pwd) + return + fi + if [[ -f "${base}/../package.json" ]]; then + (cd "${base}/.." && pwd) + return + fi + if [[ -f "${base}/../../package.json" ]]; then + (cd "${base}/../.." && pwd) + return + fi + printf "%s\n" "$base" +} +DEFAULT_NEMOCLAW_VERSION="0.1.0" +TOTAL_STEPS=3 + +resolve_installer_version() { + local repo_root + repo_root="$(resolve_repo_root)" + if [[ -n "${NEMOCLAW_INSTALL_REF:-}" && "${NEMOCLAW_INSTALL_REF}" != "latest" ]]; then + printf "%s" "${NEMOCLAW_INSTALL_REF#v}" + return + fi + # Prefer git tags (works in dev clones and CI) + if command -v git &>/dev/null && [[ -e "${repo_root}/.git" ]]; then + local git_ver="" + if git_ver="$(git -C "$repo_root" describe --tags --match 'v*' 2>/dev/null)"; then + git_ver="${git_ver#v}" + if [[ -n "$git_ver" ]]; then + printf "%s" "$git_ver" + return + fi + fi + fi + # Fall back to .version file (stamped during install) + if [[ -f "${repo_root}/.version" ]]; then + local file_ver + file_ver="$(cat "${repo_root}/.version")" + if [[ -n "$file_ver" ]]; then + printf "%s" "$file_ver" + return fi + fi + # Last resort: package.json + local package_json="${repo_root}/package.json" + local version="" + if [[ -f "$package_json" ]]; then + version="$(sed -nE 's/^[[:space:]]*"version":[[:space:]]*"([^"]+)".*/\1/p' "$package_json" | head -1)" + fi + printf "%s" "${version:-$DEFAULT_NEMOCLAW_VERSION}" +} - [ -S "$socket_path" ] - } +NEMOCLAW_VERSION="$(resolve_installer_version)" - find_colima_docker_socket() { - local home_dir="${1:-${HOME:-/tmp}}" - local socket_path +installer_version_for_display() { + if [[ -z "${NEMOCLAW_VERSION:-}" || "${NEMOCLAW_VERSION}" == "${DEFAULT_NEMOCLAW_VERSION}" ]]; then + printf "" + return + fi + printf " v%s" "$NEMOCLAW_VERSION" +} - for socket_path in \ - "$home_dir/.colima/default/docker.sock" \ - "$home_dir/.config/colima/default/docker.sock"; do - if socket_exists "$socket_path"; then - printf '%s\n' "$socket_path" - return 0 - fi - done +# Resolve which Git ref to install from. +# Priority: NEMOCLAW_INSTALL_TAG env var > "latest" tag. +resolve_release_tag() { + if [[ -n "${NEMOCLAW_INSTALL_REF:-}" ]]; then + printf "%s" "${NEMOCLAW_INSTALL_REF}" + return + fi + # Allow explicit override (for CI, pinning, or testing). + # Otherwise default to the "latest" tag, which we maintain to point at + # the commit we want everybody to install. + printf "%s" "${NEMOCLAW_INSTALL_TAG:-latest}" +} - return 1 - } +# --------------------------------------------------------------------------- +# Color / style — disabled when NO_COLOR is set or stdout is not a TTY. +# Uses exact NVIDIA green #76B900 on truecolor terminals; 256-color otherwise. +# --------------------------------------------------------------------------- +if [[ -z "${NO_COLOR:-}" && -t 1 ]]; then + if [[ "${COLORTERM:-}" == "truecolor" || "${COLORTERM:-}" == "24bit" ]]; then + C_GREEN=$'\033[38;2;118;185;0m' # #76B900 — exact NVIDIA green + else + C_GREEN=$'\033[38;5;148m' # closest 256-color on dark backgrounds + fi + C_BOLD=$'\033[1m' + C_DIM=$'\033[2m' + C_RED=$'\033[1;31m' + C_YELLOW=$'\033[1;33m' + C_CYAN=$'\033[1;36m' + C_RESET=$'\033[0m' +else + C_GREEN='' C_BOLD='' C_DIM='' C_RED='' C_YELLOW='' C_CYAN='' C_RESET='' +fi - find_docker_desktop_socket() { - local home_dir="${1:-${HOME:-/tmp}}" - local socket_path="$home_dir/.docker/run/docker.sock" +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +info() { printf "${C_CYAN}[INFO]${C_RESET} %s\n" "$*"; } +warn() { printf "${C_YELLOW}[WARN]${C_RESET} %s\n" "$*"; } +error() { + printf "${C_RED}[ERROR]${C_RESET} %s\n" "$*" >&2 + exit 1 +} +ok() { printf " ${C_GREEN}✓${C_RESET} %s\n" "$*"; } - if socket_exists "$socket_path"; then - printf '%s\n' "$socket_path" - return 0 - fi +verify_downloaded_script() { + local file="$1" label="${2:-script}" + if [ ! -s "$file" ]; then + error "$label installer download is empty or missing" + fi + if ! head -1 "$file" | grep -qE '^#!.*(sh|bash)'; then + error "$label installer does not start with a shell shebang — possible download corruption" + fi + local hash + if command -v sha256sum >/dev/null 2>&1; then + hash="$(sha256sum "$file" | awk '{print $1}')" + elif command -v shasum >/dev/null 2>&1; then + hash="$(shasum -a 256 "$file" | awk '{print $1}')" + fi + if [ -n "${hash:-}" ]; then + info "$label installer SHA-256: $hash" + fi +} - return 1 - } +resolve_default_sandbox_name() { + local registry_file="${HOME}/.nemoclaw/sandboxes.json" + local sandbox_name="${NEMOCLAW_SANDBOX_NAME:-}" + + if [[ -z "$sandbox_name" && -f "$registry_file" ]] && command_exists node; then + sandbox_name="$( + node -e ' + const fs = require("fs"); + const file = process.argv[1]; + try { + const data = JSON.parse(fs.readFileSync(file, "utf8")); + const sandboxes = data.sandboxes || {}; + const preferred = data.defaultSandbox; + const name = (preferred && sandboxes[preferred] && preferred) || Object.keys(sandboxes)[0] || ""; + process.stdout.write(name); + } catch {} + ' "$registry_file" 2>/dev/null || true + )" + fi - detect_docker_host() { - if [ -n "${DOCKER_HOST:-}" ]; then - printf '%s\n' "$DOCKER_HOST" - return 0 - fi + printf "%s" "${sandbox_name:-my-assistant}" +} - local home_dir="${1:-${HOME:-/tmp}}" - local socket_path +# step N "Description" — numbered section header +step() { + local n=$1 msg=$2 + printf "\n${C_GREEN}[%s/%s]${C_RESET} ${C_BOLD}%s${C_RESET}\n" \ + "$n" "$TOTAL_STEPS" "$msg" + printf " ${C_DIM}──────────────────────────────────────────────────${C_RESET}\n" +} - if socket_path="$(find_colima_docker_socket "$home_dir")"; then - printf 'unix://%s\n' "$socket_path" - return 0 +print_banner() { + local version_suffix + version_suffix="$(installer_version_for_display)" + printf "\n" + # ANSI Shadow ASCII art — hand-crafted, no figlet dependency + printf " ${C_GREEN}${C_BOLD} ███╗ ██╗███████╗███╗ ███╗ ██████╗ ██████╗██╗ █████╗ ██╗ ██╗${C_RESET}\n" + printf " ${C_GREEN}${C_BOLD} ████╗ ██║██╔════╝████╗ ████║██╔═══██╗██╔════╝██║ ██╔══██╗██║ ██║${C_RESET}\n" + printf " ${C_GREEN}${C_BOLD} ██╔██╗ ██║█████╗ ██╔████╔██║██║ ██║██║ ██║ ███████║██║ █╗ ██║${C_RESET}\n" + printf " ${C_GREEN}${C_BOLD} ██║╚██╗██║██╔══╝ ██║╚██╔╝██║██║ ██║██║ ██║ ██╔══██║██║███╗██║${C_RESET}\n" + printf " ${C_GREEN}${C_BOLD} ██║ ╚████║███████╗██║ ╚═╝ ██║╚██████╔╝╚██████╗███████╗██║ ██║╚███╔███╔╝${C_RESET}\n" + printf " ${C_GREEN}${C_BOLD} ╚═╝ ╚═══╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝${C_RESET}\n" + printf "\n" + printf " ${C_DIM}Launch OpenClaw in an OpenShell sandbox.%s${C_RESET}\n" "$version_suffix" + printf "\n" +} + +print_done() { + local elapsed=$((SECONDS - _INSTALL_START)) + local _needs_reload=false + needs_shell_reload && _needs_reload=true + + info "=== Installation complete ===" + printf "\n" + printf " ${C_GREEN}${C_BOLD}NemoClaw${C_RESET} ${C_DIM}(%ss)${C_RESET}\n" "$elapsed" + printf "\n" + if [[ "$ONBOARD_RAN" == true ]]; then + local sandbox_name + sandbox_name="$(resolve_default_sandbox_name)" + printf " ${C_GREEN}Your OpenClaw Sandbox is live.${C_RESET}\n" + printf " ${C_DIM}Sandbox in, break things, and tell us what you find.${C_RESET}\n" + printf "\n" + printf " ${C_GREEN}Next:${C_RESET}\n" + if [[ "$_needs_reload" == true ]]; then + printf " %s$%s source %s\n" "$C_GREEN" "$C_RESET" "$(detect_shell_profile)" + fi + printf " %s$%s nemoclaw %s connect\n" "$C_GREEN" "$C_RESET" "$sandbox_name" + printf " %ssandbox@%s$%s openclaw tui\n" "$C_GREEN" "$sandbox_name" "$C_RESET" + elif [[ "$NEMOCLAW_READY_NOW" == true ]]; then + printf " ${C_GREEN}NemoClaw CLI is installed.${C_RESET}\n" + printf " ${C_DIM}Onboarding has not run yet.${C_RESET}\n" + printf "\n" + printf " ${C_GREEN}Next:${C_RESET}\n" + if [[ "$_needs_reload" == true ]]; then + printf " %s$%s source %s\n" "$C_GREEN" "$C_RESET" "$(detect_shell_profile)" fi + printf " %s$%s nemoclaw onboard\n" "$C_GREEN" "$C_RESET" + else + printf " ${C_GREEN}NemoClaw CLI is installed.${C_RESET}\n" + printf " ${C_DIM}Onboarding did not run because this shell cannot resolve 'nemoclaw' yet.${C_RESET}\n" + printf "\n" + printf " ${C_GREEN}Next:${C_RESET}\n" + if [[ -n "$NEMOCLAW_RECOVERY_EXPORT_DIR" ]]; then + printf " %s$%s export PATH=\"%s:\$PATH\"\n" "$C_GREEN" "$C_RESET" "$NEMOCLAW_RECOVERY_EXPORT_DIR" + fi + if [[ -n "$NEMOCLAW_RECOVERY_PROFILE" ]]; then + printf " %s$%s source %s\n" "$C_GREEN" "$C_RESET" "$NEMOCLAW_RECOVERY_PROFILE" + fi + printf " %s$%s nemoclaw onboard\n" "$C_GREEN" "$C_RESET" + fi + printf "\n" + printf " ${C_BOLD}GitHub${C_RESET} ${C_DIM}https://github.com/nvidia/nemoclaw${C_RESET}\n" + printf " ${C_BOLD}Docs${C_RESET} ${C_DIM}https://docs.nvidia.com/nemoclaw/latest/${C_RESET}\n" + printf "\n" +} - if socket_path="$(find_docker_desktop_socket "$home_dir")"; then - printf 'unix://%s\n' "$socket_path" - return 0 +usage() { + local version_suffix + version_suffix="$(installer_version_for_display)" + printf "\n" + printf " ${C_BOLD}NemoClaw Installer${C_RESET}${C_DIM}%s${C_RESET}\n\n" "$version_suffix" + printf " ${C_DIM}Usage:${C_RESET}\n" + printf " curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash\n" + printf " curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash -s -- [options]\n\n" + printf " ${C_DIM}Options:${C_RESET}\n" + printf " --non-interactive Skip prompts (uses env vars / defaults)\n" + printf " --yes-i-accept-third-party-software Accept the third-party software notice in non-interactive mode\n" + printf " --version, -v Print installer version and exit\n" + printf " --help, -h Show this help message and exit\n\n" + printf " ${C_DIM}Environment:${C_RESET}\n" + printf " NVIDIA_API_KEY API key (skips credential prompt)\n" + printf " NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 Same as --yes-i-accept-third-party-software\n" + printf " NEMOCLAW_NON_INTERACTIVE=1 Same as --non-interactive\n" + printf " NEMOCLAW_SANDBOX_NAME Sandbox name to create/use\n" + printf " NEMOCLAW_RECREATE_SANDBOX=1 Recreate an existing sandbox\n" + printf " NEMOCLAW_INSTALL_TAG Git ref to install (default: latest release)\n" + printf " NEMOCLAW_PROVIDER cloud | ollama | nim | vllm\n" + printf " NEMOCLAW_MODEL Inference model to configure\n" + printf " NEMOCLAW_POLICY_MODE suggested | custom | skip\n" + printf " NEMOCLAW_POLICY_PRESETS Comma-separated policy presets\n" + printf " NEMOCLAW_EXPERIMENTAL=1 Show experimental/local options\n" + printf " CHAT_UI_URL Chat UI URL to open after setup\n" + printf " DISCORD_BOT_TOKEN Auto-enable Discord policy support\n" + printf " SLACK_BOT_TOKEN Auto-enable Slack policy support\n" + printf " TELEGRAM_BOT_TOKEN Auto-enable Telegram policy support\n" + printf "\n" +} + +show_usage_notice() { + local repo_root + repo_root="$(resolve_repo_root)" + local source_root="${NEMOCLAW_SOURCE_ROOT:-$repo_root}" + local notice_script="${source_root}/bin/lib/usage-notice.js" + if [[ ! -f "$notice_script" ]]; then + notice_script="${repo_root}/bin/lib/usage-notice.js" + fi + local -a notice_cmd=(node "$notice_script") + if [ "${NON_INTERACTIVE:-}" = "1" ]; then + notice_cmd+=(--non-interactive) + if [ "${ACCEPT_THIRD_PARTY_SOFTWARE:-}" = "1" ]; then + notice_cmd+=(--yes-i-accept-third-party-software) fi + "${notice_cmd[@]}" + elif [ -t 0 ]; then + "${notice_cmd[@]}" + elif exec 3"$log" 2>&1 & + local pid=$! i=0 + local status + local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏') + + # Register with global cleanup so any exit path reaps the child and temp file. + _cleanup_pids+=("$pid") + _cleanup_files+=("$log") + + # Ensure Ctrl+C kills the background process and cleans up the temp file. + trap 'kill "$pid" 2>/dev/null; rm -f "$log"; exit 130' INT TERM + + while kill -0 "$pid" 2>/dev/null; do + printf "\r ${C_GREEN}%s${C_RESET} %s" "${frames[$((i++ % 10))]}" "$msg" + sleep 0.08 + done + + # Restore default signal handling after the background process exits. + trap - INT TERM + + if wait "$pid"; then + status=0 + else + status=$? + fi + + if [[ $status -eq 0 ]]; then + printf "\r ${C_GREEN}✓${C_RESET} %s\n" "$msg" + else + printf "\r ${C_RED}✗${C_RESET} %s\n\n" "$msg" + cat "$log" >&2 + printf "\n" + fi + rm -f "$log" + + # Deregister only after cleanup actions are complete, so the global EXIT + # trap still covers this pid/log if a signal arrives before this point. + _cleanup_pids=("${_cleanup_pids[@]/$pid/}") + _cleanup_files=("${_cleanup_files[@]/$log/}") + return $status } -SCRIPT_PATH="${BASH_SOURCE[0]-}" -SCRIPT_DIR="" -if [ -n "$SCRIPT_PATH" ]; then - SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)" -fi +command_exists() { command -v "$1" &>/dev/null; } -if [ -n "$SCRIPT_DIR" ] && [ -f "$SCRIPT_DIR/lib/runtime.sh" ]; then - # shellcheck source=/dev/null - . "$SCRIPT_DIR/lib/runtime.sh" -else - define_runtime_helpers -fi +MIN_NODE_VERSION="22.16.0" +MIN_NPM_MAJOR=10 +RUNTIME_REQUIREMENT_MSG="NemoClaw requires Node.js >=${MIN_NODE_VERSION} and npm >=${MIN_NPM_MAJOR}." +NEMOCLAW_SHIM_DIR="${HOME}/.local/bin" +ORIGINAL_PATH="${PATH:-}" +NEMOCLAW_READY_NOW=false +NEMOCLAW_RECOVERY_PROFILE="" +NEMOCLAW_RECOVERY_EXPORT_DIR="" +NEMOCLAW_SOURCE_ROOT="$(resolve_repo_root)" +ONBOARD_RAN=false + +# Compare two semver strings (major.minor.patch). Returns 0 if $1 >= $2. +# Rejects prerelease suffixes (e.g. "22.16.0-rc.1") to avoid arithmetic errors. +version_gte() { + [[ "$1" =~ ^[0-9]+(\.[0-9]+){0,2}$ ]] || return 1 + [[ "$2" =~ ^[0-9]+(\.[0-9]+){0,2}$ ]] || return 1 + local -a a b + IFS=. read -ra a <<<"$1" + IFS=. read -ra b <<<"$2" + for i in 0 1 2; do + local ai=${a[$i]:-0} bi=${b[$i]:-0} + if ((ai > bi)); then return 0; fi + if ((ai < bi)); then return 1; fi + done + return 0 +} # Ensure nvm environment is loaded in the current shell. # Skip if node is already on PATH — sourcing nvm.sh can reset PATH and # override the caller's node/npm (e.g. in test environments with stubs). +# Pass --force to load nvm even when node is on PATH (needed when upgrading). ensure_nvm_loaded() { - command -v node &>/dev/null && return 0 - if [ -z "${NVM_DIR:-}" ]; then + if [[ "${1:-}" != "--force" ]]; then + command -v node &>/dev/null && return 0 + fi + if [[ -z "${NVM_DIR:-}" ]]; then export NVM_DIR="$HOME/.nvm" fi - if [ -s "$NVM_DIR/nvm.sh" ]; then - # shellcheck source=/dev/null - . "$NVM_DIR/nvm.sh" + if [[ -s "$NVM_DIR/nvm.sh" ]]; then + \. "$NVM_DIR/nvm.sh" fi } +detect_shell_profile() { + local profile="$HOME/.bashrc" + case "$(basename "${SHELL:-}")" in + zsh) + profile="$HOME/.zshrc" + ;; + fish) + profile="$HOME/.config/fish/config.fish" + ;; + tcsh) + profile="$HOME/.tcshrc" + ;; + csh) + profile="$HOME/.cshrc" + ;; + *) + if [[ ! -f "$HOME/.bashrc" && -f "$HOME/.profile" ]]; then + profile="$HOME/.profile" + fi + ;; + esac + printf "%s" "$profile" +} + # Refresh PATH so that npm global bin is discoverable. +# After nvm installs Node.js the global bin lives under the nvm prefix, +# which may not yet be on PATH in the current session. refresh_path() { ensure_nvm_loaded local npm_bin npm_bin="$(npm config get prefix 2>/dev/null)/bin" || true - if [ -n "$npm_bin" ] && [ -d "$npm_bin" ]; then - case ":$PATH:" in - *":$npm_bin:"*) ;; # already on PATH - *) export PATH="$npm_bin:$PATH" ;; - esac + if [[ -n "$npm_bin" && -d "$npm_bin" && ":$PATH:" != *":$npm_bin:"* ]]; then + export PATH="$npm_bin:$PATH" fi -} - -MIN_NODE_MAJOR=20 -MIN_NPM_MAJOR=10 -RECOMMENDED_NODE_MAJOR=22 -RUNTIME_REQUIREMENT_MSG="NemoClaw requires Node.js >=${MIN_NODE_MAJOR} and npm >=${MIN_NPM_MAJOR} (recommended Node.js ${RECOMMENDED_NODE_MAJOR})." - -OS="$(uname -s)" -ARCH="$(uname -m)" - -case "$OS" in - Darwin) OS_LABEL="macOS" ;; - Linux) OS_LABEL="Linux" ;; - *) fail "Unsupported OS: $OS" ;; -esac - -case "$ARCH" in - x86_64 | amd64) ARCH_LABEL="x86_64" ;; - aarch64 | arm64) ARCH_LABEL="aarch64" ;; - *) fail "Unsupported architecture: $ARCH" ;; -esac - -info "Detected $OS_LABEL ($ARCH_LABEL)" - -# ── Detect Node.js version manager ────────────────────────────── - -NODE_MGR="none" -NEED_RESHIM=false - -if command -v asdf >/dev/null 2>&1 && asdf plugin list 2>/dev/null | grep -q nodejs; then - NODE_MGR="asdf" -elif [ -n "${NVM_DIR:-}" ] && [ -s "${NVM_DIR}/nvm.sh" ]; then - NODE_MGR="nvm" -elif [ -s "$HOME/.nvm/nvm.sh" ]; then - export NVM_DIR="$HOME/.nvm" - NODE_MGR="nvm" -elif command -v fnm >/dev/null 2>&1; then - NODE_MGR="fnm" -elif command -v brew >/dev/null 2>&1 && [ "$OS" = "Darwin" ]; then - NODE_MGR="brew" -elif [ "$OS" = "Linux" ]; then - NODE_MGR="nodesource" -fi - -info "Node.js manager: $NODE_MGR" -version_major() { - printf '%s\n' "${1#v}" | cut -d. -f1 + if [[ -d "$NEMOCLAW_SHIM_DIR" && ":$PATH:" != *":$NEMOCLAW_SHIM_DIR:"* ]]; then + export PATH="$NEMOCLAW_SHIM_DIR:$PATH" + fi } -ensure_supported_runtime() { - command -v node >/dev/null 2>&1 || fail "${RUNTIME_REQUIREMENT_MSG} Node.js was not found on PATH." - command -v npm >/dev/null 2>&1 || fail "${RUNTIME_REQUIREMENT_MSG} npm was not found on PATH." - - local node_version npm_version node_major npm_major - node_version="$(node -v 2>/dev/null || true)" - npm_version="$(npm --version 2>/dev/null || true)" - node_major="$(version_major "$node_version")" - npm_major="$(version_major "$npm_version")" +ensure_nemoclaw_shim() { + local npm_bin shim_path + npm_bin="$(npm config get prefix 2>/dev/null)/bin" || true + shim_path="${NEMOCLAW_SHIM_DIR}/nemoclaw" - [[ "$node_major" =~ ^[0-9]+$ ]] || fail "Could not determine Node.js version from '${node_version}'. ${RUNTIME_REQUIREMENT_MSG}" - [[ "$npm_major" =~ ^[0-9]+$ ]] || fail "Could not determine npm version from '${npm_version}'. ${RUNTIME_REQUIREMENT_MSG}" + if [[ -z "$npm_bin" || ! -x "$npm_bin/nemoclaw" ]]; then + return 1 + fi - if ((node_major < MIN_NODE_MAJOR || npm_major < MIN_NPM_MAJOR)); then - fail "Unsupported runtime detected: Node.js ${node_version:-unknown}, npm ${npm_version:-unknown}. ${RUNTIME_REQUIREMENT_MSG} Upgrade Node.js and rerun the installer." + if [[ ":$ORIGINAL_PATH:" == *":$npm_bin:"* ]] || [[ ":$ORIGINAL_PATH:" == *":$NEMOCLAW_SHIM_DIR:"* ]]; then + return 0 fi - info "Runtime OK: Node.js ${node_version}, npm ${npm_version}" + mkdir -p "$NEMOCLAW_SHIM_DIR" + ln -sfn "$npm_bin/nemoclaw" "$shim_path" + refresh_path + ensure_local_bin_in_profile + info "Created user-local shim at $shim_path" + return 0 } -# ── Install Node.js 22 if needed ──────────────────────────────── - -install_node() { - local current_major="" - if command -v node >/dev/null 2>&1; then - current_major="$(node -v 2>/dev/null | sed 's/^v//' | cut -d. -f1)" - fi +# Detect whether the parent shell likely needs a reload after install. +# When running via `curl | bash`, the installer executes in a subprocess. +# Even when the bin directory is already in PATH, the parent shell may have +# stale bash hash-table entries pointing to a previously deleted binary +# (e.g. upgrade/reinstall after `rm $(which nemoclaw)`). Sourcing the +# shell profile reassigns PATH which clears the hash table, so we always +# recommend it when the installer verified nemoclaw in the subprocess. +needs_shell_reload() { + [[ "$NEMOCLAW_READY_NOW" != true ]] && return 1 + return 0 +} - if [ "$current_major" = "22" ]; then - info "Node.js 22 already installed: $(node -v)" +# Add ~/.local/bin (and for fish, the nvm node bin) to the user's shell +# profile PATH so that nemoclaw, openshell, and any future tools installed +# there are discoverable in new terminal sessions. +# Idempotent — skips if the marker comment is already present. +ensure_local_bin_in_profile() { + local profile + profile="$(detect_shell_profile)" + [[ -n "$profile" ]] || return 0 + + # Already present — nothing to do. + if [[ -f "$profile" ]] && grep -qF '# NemoClaw PATH setup' "$profile" 2>/dev/null; then return 0 fi - info "Installing Node.js 22..." + local shell_name + shell_name="$(basename "${SHELL:-bash}")" - case "$NODE_MGR" in - asdf) - local latest_22 - latest_22="$(asdf list all nodejs 2>/dev/null | grep '^22\.' | tail -1)" - [ -n "$latest_22" ] || fail "Could not find Node.js 22 in asdf" - asdf install nodejs "$latest_22" - asdf global nodejs "$latest_22" - NEED_RESHIM=true - ;; - nvm) - # shellcheck source=/dev/null - . "${NVM_DIR}/nvm.sh" - nvm install 22 - nvm use 22 - nvm alias default 22 - ;; - fnm) - fnm install 22 - fnm use 22 - fnm default 22 - ;; - brew) - brew install node@22 - brew link --overwrite node@22 2>/dev/null || true + local local_bin="$NEMOCLAW_SHIM_DIR" + + case "$shell_name" in + fish) + # fish needs both ~/.local/bin and the nvm node bin (nvm doesn't support fish). + local node_bin="" + node_bin="$(command -v node 2>/dev/null)" || true + if [[ -n "$node_bin" ]]; then + node_bin="$(dirname "$node_bin")" + fi + { + printf '\n# NemoClaw PATH setup\n' + printf 'fish_add_path --path --append "%s"\n' "$local_bin" + if [[ -n "$node_bin" ]]; then + printf 'fish_add_path --path --append "%s"\n' "$node_bin" + fi + printf '# end NemoClaw PATH setup\n' + } >>"$profile" ;; - nodesource) - curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - >/dev/null 2>&1 - sudo apt-get install -y -qq nodejs >/dev/null 2>&1 + tcsh | csh) + { + printf '\n# NemoClaw PATH setup\n' + # shellcheck disable=SC2016 + printf 'setenv PATH "%s:${PATH}"\n' "$local_bin" + printf '# end NemoClaw PATH setup\n' + } >>"$profile" ;; - none) - fail "No Node.js version manager found. Install Node.js 22 manually, then re-run." + *) + # bash, zsh, and others — nvm already handles node PATH for these shells. + { + printf '\n# NemoClaw PATH setup\n' + # shellcheck disable=SC2016 + printf 'export PATH="%s:$PATH"\n' "$local_bin" + printf '# end NemoClaw PATH setup\n' + } >>"$profile" ;; esac - - info "Node.js $(node -v) installed" } -install_node -ensure_supported_runtime - -# ── Install Docker ─────────────────────────────────────────────── +version_major() { + printf '%s\n' "${1#v}" | cut -d. -f1 +} -install_docker() { - if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then - info "Docker already running" - return 0 - fi +ensure_supported_runtime() { + command_exists node || error "${RUNTIME_REQUIREMENT_MSG} Node.js was not found on PATH." + command_exists npm || error "${RUNTIME_REQUIREMENT_MSG} npm was not found on PATH." - if command -v docker >/dev/null 2>&1; then - # Docker installed but not running - if [ "$OS" = "Darwin" ]; then - local colima_socket="" - local docker_desktop_socket="" - colima_socket="$(find_colima_docker_socket || true)" - docker_desktop_socket="$(find_docker_desktop_socket || true)" + local node_version npm_version node_major npm_major + node_version="$(node --version 2>/dev/null || true)" + npm_version="$(npm --version 2>/dev/null || true)" + node_major="$(version_major "$node_version")" + npm_major="$(version_major "$npm_version")" - if [ -n "${DOCKER_HOST:-}" ]; then - fail "Docker is installed but the selected runtime is not running. Start the runtime behind DOCKER_HOST (${DOCKER_HOST}) and re-run." - fi + [[ "$node_major" =~ ^[0-9]+$ ]] || error "Could not determine Node.js version from '${node_version}'. ${RUNTIME_REQUIREMENT_MSG}" + [[ "$npm_major" =~ ^[0-9]+$ ]] || error "Could not determine npm version from '${npm_version}'. ${RUNTIME_REQUIREMENT_MSG}" - if [ -n "$colima_socket" ] && [ -n "$docker_desktop_socket" ]; then - fail "Both Colima and Docker Desktop are available on this Mac. Start the runtime you want explicitly and re-run, or set DOCKER_HOST to select one." - fi + if ! version_gte "${node_version#v}" "$MIN_NODE_VERSION" || ((npm_major < MIN_NPM_MAJOR)); then + error "Unsupported runtime detected: Node.js ${node_version:-unknown}, npm ${npm_version:-unknown}. ${RUNTIME_REQUIREMENT_MSG} Upgrade Node.js and rerun the installer." + fi - if [ -n "$docker_desktop_socket" ]; then - fail "Docker Desktop appears to be installed but is not running. Start Docker Desktop and re-run." - fi + info "Runtime OK: Node.js ${node_version}, npm ${npm_version}" +} - if command -v colima >/dev/null 2>&1; then - info "Starting Colima..." - colima start - return 0 - fi +# --------------------------------------------------------------------------- +# 1. Node.js +# --------------------------------------------------------------------------- +install_nodejs() { + if command_exists node; then + local current_version current_npm_major + current_version="$(node --version 2>/dev/null || true)" + current_npm_major="$(version_major "$(npm --version 2>/dev/null || echo 0)")" + if version_gte "${current_version#v}" "$MIN_NODE_VERSION" \ + && [[ "$current_npm_major" =~ ^[0-9]+$ ]] \ + && ((current_npm_major >= MIN_NPM_MAJOR)); then + info "Node.js found: ${current_version}" + return fi - fail "Docker is installed but not running. Please start Docker and re-run." + warn "Node.js ${current_version}, npm major ${current_npm_major:-unknown} found but NemoClaw requires Node.js >=${MIN_NODE_VERSION} and npm >=${MIN_NPM_MAJOR} — upgrading via nvm…" + else + info "Node.js not found — installing via nvm…" + fi + # IMPORTANT: update NVM_SHA256 when changing NVM_VERSION + local NVM_VERSION="v0.40.4" + local NVM_SHA256="4b7412c49960c7d31e8df72da90c1fb5b8cccb419ac99537b737028d497aba4f" + local nvm_tmp + nvm_tmp="$(mktemp)" + curl -fsSL "https://raw.githubusercontent.com/nvm-sh/nvm/${NVM_VERSION}/install.sh" -o "$nvm_tmp" \ + || { + rm -f "$nvm_tmp" + error "Failed to download nvm installer" + } + local actual_hash + if command_exists sha256sum; then + actual_hash="$(sha256sum "$nvm_tmp" | awk '{print $1}')" + elif command_exists shasum; then + actual_hash="$(shasum -a 256 "$nvm_tmp" | awk '{print $1}')" + else + warn "No SHA-256 tool found — skipping nvm integrity check" + actual_hash="$NVM_SHA256" # allow execution fi + if [[ "$actual_hash" != "$NVM_SHA256" ]]; then + rm -f "$nvm_tmp" + error "nvm installer integrity check failed\n Expected: $NVM_SHA256\n Actual: $actual_hash" + fi + info "nvm installer integrity verified" + spin "Installing nvm..." bash "$nvm_tmp" + rm -f "$nvm_tmp" + ensure_nvm_loaded --force + spin "Installing Node.js 22..." bash -c ". \"$NVM_DIR/nvm.sh\" && nvm install 22 --no-progress" + ensure_nvm_loaded --force + nvm use 22 --silent + nvm alias default 22 2>/dev/null || true + info "Node.js installed: $(node --version)" +} - info "Installing Docker..." +# --------------------------------------------------------------------------- +# 2. Ollama +# --------------------------------------------------------------------------- +OLLAMA_MIN_VERSION="0.18.0" - case "$OS" in - Darwin) - if ! command -v brew >/dev/null 2>&1; then - fail "Homebrew required to install Docker on macOS. Install from https://brew.sh" - fi - info "Installing Colima + Docker CLI via Homebrew..." - brew install colima docker - info "Starting Colima..." - colima start - ;; - Linux) - sudo apt-get update -qq >/dev/null 2>&1 - sudo apt-get install -y -qq docker.io >/dev/null 2>&1 - sudo usermod -aG docker "$(whoami)" - info "Docker installed. You may need to log out and back in for group changes." - ;; - esac +get_ollama_version() { + # `ollama --version` outputs something like "ollama version 0.18.0" + ollama --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 +} - if ! docker info >/dev/null 2>&1; then - fail "Docker installed but not running. Start Docker and re-run." +detect_gpu() { + # Returns 0 if a GPU is detected + if command_exists nvidia-smi; then + nvidia-smi &>/dev/null && return 0 fi + return 1 +} - info "Docker is running" +get_vram_mb() { + # Returns total VRAM in MiB (NVIDIA only). Falls back to 0. + if command_exists nvidia-smi; then + nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits 2>/dev/null \ + | awk '{s += $1} END {print s+0}' + return + fi + # macOS — report unified memory as VRAM + if [[ "$(uname -s)" == "Darwin" ]] && command_exists sysctl; then + local bytes + bytes=$(sysctl -n hw.memsize 2>/dev/null || echo 0) + echo $((bytes / 1024 / 1024)) + return + fi + echo 0 } -install_docker +install_or_upgrade_ollama() { + if detect_gpu && command_exists ollama; then + local current + current=$(get_ollama_version) + if [[ -n "$current" ]] && version_gte "$current" "$OLLAMA_MIN_VERSION"; then + info "Ollama v${current} meets minimum requirement (>= v${OLLAMA_MIN_VERSION})" + else + info "Ollama v${current:-unknown} is below v${OLLAMA_MIN_VERSION} — upgrading…" + ( + tmpdir="$(mktemp -d)" + trap 'rm -rf "$tmpdir"' EXIT + curl -fsSL https://ollama.com/install.sh -o "$tmpdir/install_ollama.sh" + verify_downloaded_script "$tmpdir/install_ollama.sh" "Ollama" + sh "$tmpdir/install_ollama.sh" + ) + info "Ollama upgraded to $(get_ollama_version)" + fi + else + # No ollama — only install if a GPU is present + if detect_gpu; then + info "GPU detected — installing Ollama…" + ( + tmpdir="$(mktemp -d)" + trap 'rm -rf "$tmpdir"' EXIT + curl -fsSL https://ollama.com/install.sh -o "$tmpdir/install_ollama.sh" + verify_downloaded_script "$tmpdir/install_ollama.sh" "Ollama" + sh "$tmpdir/install_ollama.sh" + ) + info "Ollama installed: v$(get_ollama_version)" + else + warn "No GPU detected — skipping Ollama installation." + return + fi + fi -# ── Install OpenShell CLI binary ───────────────────────────────── + # Pull the appropriate model based on VRAM + local vram_mb + vram_mb=$(get_vram_mb) + local vram_gb=$((vram_mb / 1024)) + info "Detected ${vram_gb} GB VRAM" -install_openshell() { - if command -v openshell >/dev/null 2>&1; then - info "openshell already installed: $(openshell --version 2>&1 || echo 'unknown')" - return 0 + if ((vram_gb >= 120)); then + info "Pulling nemotron-3-super:120b…" + ollama pull nemotron-3-super:120b + else + info "Pulling nemotron-3-nano:30b…" + ollama pull nemotron-3-nano:30b fi +} - info "Installing openshell CLI..." +# --------------------------------------------------------------------------- +# Fix npm permissions for global installs (Linux only). +# If the npm global prefix points to a system directory (e.g. /usr or +# /usr/local) the user likely lacks write permissions and npm link will fail +# with EACCES. Redirect the prefix to ~/.npm-global so the install succeeds +# without sudo. +# --------------------------------------------------------------------------- +fix_npm_permissions() { + if [[ "$(uname -s)" != "Linux" ]]; then + return 0 + fi - case "$OS" in - Darwin) - case "$ARCH_LABEL" in - x86_64) ASSET="openshell-x86_64-apple-darwin.tar.gz" ;; - aarch64) ASSET="openshell-aarch64-apple-darwin.tar.gz" ;; - esac - ;; - Linux) - case "$ARCH_LABEL" in - x86_64) ASSET="openshell-x86_64-unknown-linux-musl.tar.gz" ;; - aarch64) ASSET="openshell-aarch64-unknown-linux-musl.tar.gz" ;; - esac - ;; - esac + local npm_prefix + npm_prefix="$(npm config get prefix 2>/dev/null || true)" + if [[ -z "$npm_prefix" ]]; then + return 0 + fi - tmpdir="$(mktemp -d)" - if command -v gh >/dev/null 2>&1; then - GH_TOKEN="${GITHUB_TOKEN:-}" gh release download --repo NVIDIA/OpenShell \ - --pattern "$ASSET" --dir "$tmpdir" - else - # Fallback: curl latest release - curl -fsSL "https://github.com/NVIDIA/OpenShell/releases/latest/download/$ASSET" \ - -o "$tmpdir/$ASSET" + if [[ -w "$npm_prefix" || -w "$npm_prefix/lib" ]]; then + return 0 fi - tar xzf "$tmpdir/$ASSET" -C "$tmpdir" + info "npm global prefix '${npm_prefix}' is not writable — configuring user-local installs" + mkdir -p "$HOME/.npm-global" + npm config set prefix "$HOME/.npm-global" - if [ -w /usr/local/bin ]; then - install -m 755 "$tmpdir/openshell" /usr/local/bin/openshell - else - sudo install -m 755 "$tmpdir/openshell" /usr/local/bin/openshell - fi + # shellcheck disable=SC2016 + local path_line='export PATH="$HOME/.npm-global/bin:$PATH"' + for rc in "$HOME/.bashrc" "$HOME/.zshrc"; do + if [[ -f "$rc" ]] && ! grep -q ".npm-global" "$rc"; then + printf '\n# Added by NemoClaw installer\n%s\n' "$path_line" >>"$rc" + fi + done - rm -rf "$tmpdir" - info "openshell $(openshell --version 2>&1 || echo '') installed" + export PATH="$HOME/.npm-global/bin:$PATH" + ok "npm configured for user-local installs (~/.npm-global)" } -install_openshell - -# ── Pre-extract openclaw workaround (GH-503) ──────────────────── -# The openclaw npm tarball is missing directory entries for extensions/, -# skills/, and dist/plugin-sdk/config/. npm's tar extractor hard-fails on -# these but system tar handles them fine. We pre-extract openclaw into -# node_modules BEFORE npm install so npm sees the dep is already satisfied. +# --------------------------------------------------------------------------- +# 3. NemoClaw +# --------------------------------------------------------------------------- +# Work around openclaw tarball missing directory entries (GH-503). +# npm's tar extractor hard-fails because the tarball is missing directory +# entries for extensions/, skills/, and dist/plugin-sdk/config/. System tar +# handles this fine. We pre-extract openclaw into node_modules BEFORE npm +# install so npm sees the dependency is already satisfied and skips it. pre_extract_openclaw() { local install_dir="$1" local openclaw_version - openclaw_version=$(node -e "console.log(require('${install_dir}/package.json').dependencies.openclaw)" 2>/dev/null) || openclaw_version="" + openclaw_version="$(resolve_openclaw_version "$install_dir")" - if [ -z "$openclaw_version" ]; then + if [[ -z "$openclaw_version" ]]; then warn "Could not determine openclaw version — skipping pre-extraction" return 1 fi @@ -385,7 +764,7 @@ pre_extract_openclaw() { if npm pack "openclaw@${openclaw_version}" --pack-destination "$tmpdir" >/dev/null 2>&1; then local tgz tgz="$(find "$tmpdir" -maxdepth 1 -name 'openclaw-*.tgz' -print -quit)" - if [ -n "$tgz" ] && [ -f "$tgz" ]; then + if [[ -n "$tgz" && -f "$tgz" ]]; then if mkdir -p "${install_dir}/node_modules/openclaw" \ && tar xzf "$tgz" -C "${install_dir}/node_modules/openclaw" --strip-components=1; then info "openclaw pre-extracted successfully" @@ -407,80 +786,255 @@ pre_extract_openclaw() { rm -rf "$tmpdir" } -# ── Install NemoClaw CLI ───────────────────────────────────────── - -info "Installing nemoclaw CLI..." -# Clone first so we can pre-extract openclaw before npm install (GH-503). -# npm install -g git+https://... does this internally but we can't hook -# into its extraction pipeline, so we do it ourselves. -NEMOCLAW_SRC="${HOME}/.nemoclaw/source" -rm -rf "$NEMOCLAW_SRC" -mkdir -p "$(dirname "$NEMOCLAW_SRC")" -git clone --depth 1 https://github.com/NVIDIA/NemoClaw.git "$NEMOCLAW_SRC" -pre_extract_openclaw "$NEMOCLAW_SRC" || warn "Pre-extraction failed — npm install may fail if openclaw tarball is broken" -# Use sudo for npm link when the global prefix requires it (e.g., nodesource), -# but skip sudo if already root (e.g., Docker containers). -SUDO="" -if [ "$NODE_MGR" = "nodesource" ] && [ "$(id -u)" -ne 0 ]; then - SUDO="sudo" -fi -(cd "$NEMOCLAW_SRC" && npm install --ignore-scripts && cd nemoclaw && npm install --ignore-scripts && npm run build && cd .. && $SUDO npm link) - -if [ "$NEED_RESHIM" = true ]; then - info "Reshimming asdf..." - asdf reshim nodejs -fi +resolve_openclaw_version() { + local install_dir="$1" + local package_json dockerfile_base resolved_version + + package_json="${install_dir}/package.json" + dockerfile_base="${install_dir}/Dockerfile.base" + + if [[ -f "$package_json" ]]; then + resolved_version="$( + node -e "const v = require('${package_json}').dependencies?.openclaw; if (v) console.log(v)" \ + 2>/dev/null || true + )" + if [[ -n "$resolved_version" ]]; then + printf '%s\n' "$resolved_version" + return 0 + fi + fi -refresh_path + if [[ -f "$dockerfile_base" ]]; then + awk ' + match($0, /openclaw@[0-9][0-9.]+/) { + print substr($0, RSTART + 9, RLENGTH - 9) + exit + } + ' "$dockerfile_base" + fi +} -# ── Verify ─────────────────────────────────────────────────────── +install_nemoclaw() { + command_exists git || error "git was not found on PATH." + local repo_root package_json + repo_root="$(resolve_repo_root)" + package_json="${repo_root}/package.json" + if [[ -f "$package_json" ]] && grep -q '"name"[[:space:]]*:[[:space:]]*"nemoclaw"' "$package_json" 2>/dev/null; then + info "NemoClaw package.json found in the selected source checkout — installing from source…" + NEMOCLAW_SOURCE_ROOT="$repo_root" + spin "Preparing OpenClaw package" bash -c "$(declare -f info warn resolve_openclaw_version pre_extract_openclaw); pre_extract_openclaw \"\$1\"" _ "$NEMOCLAW_SOURCE_ROOT" \ + || warn "Pre-extraction failed — npm install may fail if openclaw tarball is broken" + spin "Installing NemoClaw dependencies" bash -c "cd \"$NEMOCLAW_SOURCE_ROOT\" && npm install --ignore-scripts" + spin "Building NemoClaw CLI modules" bash -c "cd \"$NEMOCLAW_SOURCE_ROOT\" && npm run --if-present build:cli" + spin "Building NemoClaw plugin" bash -c "cd \"$NEMOCLAW_SOURCE_ROOT\"/nemoclaw && npm install --ignore-scripts && npm run build" + spin "Linking NemoClaw CLI" bash -c "cd \"$NEMOCLAW_SOURCE_ROOT\" && npm link" + else + info "Installing NemoClaw from GitHub…" + # Resolve the latest release tag so we never install raw main. + local release_ref + release_ref="$(resolve_release_tag)" + info "Resolved install ref: ${release_ref}" + # Clone first so we can pre-extract openclaw before npm install (GH-503). + # npm install -g git+https://... does this internally but we can't hook + # into its extraction pipeline, so we do it ourselves. + local nemoclaw_src="${HOME}/.nemoclaw/source" + rm -rf "$nemoclaw_src" + mkdir -p "$(dirname "$nemoclaw_src")" + NEMOCLAW_SOURCE_ROOT="$nemoclaw_src" + spin "Cloning NemoClaw source" git clone --depth 1 --branch "$release_ref" https://github.com/NVIDIA/NemoClaw.git "$nemoclaw_src" + # Fetch version tags into the shallow clone so `git describe --tags + # --match "v*"` works at runtime (the shallow clone only has the + # single ref we asked for). + git -C "$nemoclaw_src" fetch --depth=1 origin 'refs/tags/v*:refs/tags/v*' 2>/dev/null || true + # Also stamp .version as a fallback for environments where git is + # unavailable or tags are pruned later. + git -C "$nemoclaw_src" describe --tags --match 'v*' 2>/dev/null \ + | sed 's/^v//' >"$nemoclaw_src/.version" || true + spin "Preparing OpenClaw package" bash -c "$(declare -f info warn resolve_openclaw_version pre_extract_openclaw); pre_extract_openclaw \"\$1\"" _ "$nemoclaw_src" \ + || warn "Pre-extraction failed — npm install may fail if openclaw tarball is broken" + spin "Installing NemoClaw dependencies" bash -c "cd \"$nemoclaw_src\" && npm install --ignore-scripts" + spin "Building NemoClaw CLI modules" bash -c "cd \"$nemoclaw_src\" && npm run --if-present build:cli" + spin "Building NemoClaw plugin" bash -c "cd \"$nemoclaw_src\"/nemoclaw && npm install --ignore-scripts && npm run build" + spin "Linking NemoClaw CLI" bash -c "cd \"$nemoclaw_src\" && npm link" + fi -if ! command -v nemoclaw >/dev/null 2>&1; then - # Try refreshing PATH one more time refresh_path -fi + ensure_nemoclaw_shim || true +} -if ! command -v nemoclaw >/dev/null 2>&1; then +# --------------------------------------------------------------------------- +# 4. Verify +# --------------------------------------------------------------------------- +verify_nemoclaw() { + if command_exists nemoclaw; then + NEMOCLAW_READY_NOW=true + ensure_nemoclaw_shim || true + info "Verified: nemoclaw is available at $(command -v nemoclaw)" + return 0 + fi + + local npm_bin npm_bin="$(npm config get prefix 2>/dev/null)/bin" || true - if [ -n "$npm_bin" ] && [ -x "$npm_bin/nemoclaw" ]; then - warn "nemoclaw installed at $npm_bin/nemoclaw but not on current PATH." - warn "" - warn "Add it to your shell profile:" - warn " echo 'export PATH=\"$npm_bin:\$PATH\"' >> ~/.bashrc" - warn " source ~/.bashrc" - warn "" - warn "Or for zsh:" - warn " echo 'export PATH=\"$npm_bin:\$PATH\"' >> ~/.zshrc" - warn " source ~/.zshrc" + + if [[ -n "$npm_bin" && -x "$npm_bin/nemoclaw" ]]; then + ensure_nemoclaw_shim || true + if command_exists nemoclaw; then + NEMOCLAW_READY_NOW=true + info "Verified: nemoclaw is available at $(command -v nemoclaw)" + return 0 + fi + + NEMOCLAW_RECOVERY_PROFILE="$(detect_shell_profile)" + if [[ -x "$NEMOCLAW_SHIM_DIR/nemoclaw" ]]; then + NEMOCLAW_RECOVERY_EXPORT_DIR="$NEMOCLAW_SHIM_DIR" + else + NEMOCLAW_RECOVERY_EXPORT_DIR="$npm_bin" + fi + warn "Found nemoclaw at $npm_bin/nemoclaw but this shell still cannot resolve it." + warn "Onboarding will be skipped until PATH is updated." + return 0 else - fail "nemoclaw installation failed. Binary not found." + warn "Could not locate the nemoclaw executable." + warn "Try running: npm install -g git+https://github.com/NVIDIA/NemoClaw.git" fi -fi -echo "" -info "Installation complete!" -info "nemoclaw $(nemoclaw --version 2>/dev/null || echo 'v0.1.0') is ready." -echo "" -echo " Run \`nemoclaw onboard\` to get started" -echo "" + error "Installation failed: nemoclaw binary not found." +} + +# --------------------------------------------------------------------------- +# 5. Onboard +# --------------------------------------------------------------------------- +run_onboard() { + show_usage_notice + info "Running nemoclaw onboard…" + local -a onboard_cmd=(onboard) + if command_exists node && [[ -f "${HOME}/.nemoclaw/onboard-session.json" ]]; then + if node -e ' + const fs = require("fs"); + const file = process.argv[1]; + try { + const data = JSON.parse(fs.readFileSync(file, "utf8")); + const resumable = data && data.resumable !== false; + const status = data && data.status; + process.exit(resumable && status && status !== "complete" ? 0 : 1); + } catch { + process.exit(1); + } + ' "${HOME}/.nemoclaw/onboard-session.json"; then + info "Found an interrupted onboarding session — resuming it." + onboard_cmd+=(--resume) + fi + fi + if [ "${NON_INTERACTIVE:-}" = "1" ]; then + onboard_cmd+=(--non-interactive) + if [ "${ACCEPT_THIRD_PARTY_SOFTWARE:-}" = "1" ]; then + onboard_cmd+=(--yes-i-accept-third-party-software) + fi + nemoclaw "${onboard_cmd[@]}" + elif [ -t 0 ]; then + nemoclaw "${onboard_cmd[@]}" + elif exec 3/dev/null; then + echo "[SECURITY] Could not set soft nproc limit (container runtime may restrict ulimit)" >&2 +fi +if ! ulimit -Hu 512 2>/dev/null; then + echo "[SECURITY] Could not set hard nproc limit (container runtime may restrict ulimit)" >&2 +fi + # SECURITY: Lock down PATH so the agent cannot inject malicious binaries # into commands executed by the entrypoint or auto-pair watcher. export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" -# Filter out self-invocation: openshell sandbox create passes "nemoclaw-start" -# as the command, but since this script is now the ENTRYPOINT, receiving our -# own name as $1 would cause infinite recursion via the NEMOCLAW_CMD exec path. -# Only strip from $1 — later args with this name are legitimate user arguments. +# ── Drop unnecessary Linux capabilities ────────────────────────── +# CIS Docker Benchmark 5.3: containers should not run with default caps. +# OpenShell manages the container runtime so we cannot pass --cap-drop=ALL +# to docker run. Instead, drop dangerous capabilities from the bounding set +# at startup using capsh. The bounding set limits what caps any child process +# (gateway, sandbox, agent) can ever acquire. +# +# Kept: cap_chown, cap_setuid, cap_setgid, cap_fowner, cap_kill +# — required by the entrypoint for gosu privilege separation and chown. +# Ref: https://github.com/NVIDIA/NemoClaw/issues/797 +if [ "${NEMOCLAW_CAPS_DROPPED:-}" != "1" ] && command -v capsh >/dev/null 2>&1; then + # capsh --drop requires CAP_SETPCAP in the bounding set. OpenShell's + # sandbox runtime may strip it, so check before attempting the drop. + if capsh --has-p=cap_setpcap 2>/dev/null; then + export NEMOCLAW_CAPS_DROPPED=1 + exec capsh \ + --drop=cap_net_raw,cap_dac_override,cap_sys_chroot,cap_fsetid,cap_setfcap,cap_mknod,cap_audit_write,cap_net_bind_service \ + -- -c 'exec /usr/local/bin/nemoclaw-start "$@"' -- "$@" + else + echo "[SECURITY] CAP_SETPCAP not available — runtime already restricts capabilities" >&2 + fi +elif [ "${NEMOCLAW_CAPS_DROPPED:-}" != "1" ]; then + echo "[SECURITY WARNING] capsh not available — running with default capabilities" >&2 +fi + +# Normalize the sandbox-create bootstrap wrapper. Onboard launches the +# container as `env CHAT_UI_URL=... nemoclaw-start`, but this script is already +# the ENTRYPOINT. If we treat that wrapper as a real command, the root path will +# try `gosu sandbox env ... nemoclaw-start`, which fails on Spark/arm64 when +# no-new-privileges blocks gosu. Consume only the self-wrapper form and promote +# the env assignments into the current process. +if [ "${1:-}" = "env" ]; then + _raw_args=("$@") + _self_wrapper_index="" + for ((i = 1; i < ${#_raw_args[@]}; i += 1)); do + case "${_raw_args[$i]}" in + *=*) ;; + nemoclaw-start | /usr/local/bin/nemoclaw-start) + _self_wrapper_index="$i" + break + ;; + *) + break + ;; + esac + done + if [ -n "$_self_wrapper_index" ]; then + for ((i = 1; i < _self_wrapper_index; i += 1)); do + export "${_raw_args[$i]}" + done + set -- "${_raw_args[@]:$((_self_wrapper_index + 1))}" + fi +fi + +# Filter out direct self-invocation too. Since this script is the ENTRYPOINT, +# receiving our own name as $1 would otherwise recurse via the NEMOCLAW_CMD +# exec path. Only strip from $1 — later args with this name are legitimate. case "${1:-}" in nemoclaw-start | /usr/local/bin/nemoclaw-start) shift ;; esac @@ -38,13 +104,13 @@ OPENCLAW="$(command -v openclaw)" # Resolve once, use absolute path everywhere verify_config_integrity() { local hash_file="/sandbox/.openclaw/.config-hash" if [ ! -f "$hash_file" ]; then - echo "[SECURITY] Config hash file missing — refusing to start without integrity verification" + echo "[SECURITY] Config hash file missing — refusing to start without integrity verification" >&2 return 1 fi if ! (cd /sandbox/.openclaw && sha256sum -c "$hash_file" --status 2>/dev/null); then - echo "[SECURITY] openclaw.json integrity check FAILED — config may have been tampered with" - echo "[SECURITY] Expected hash: $(cat "$hash_file")" - echo "[SECURITY] Actual hash: $(sha256sum /sandbox/.openclaw/openclaw.json)" + echo "[SECURITY] openclaw.json integrity check FAILED — config may have been tampered with" >&2 + echo "[SECURITY] Expected hash: $(cat "$hash_file")" >&2 + echo "[SECURITY] Actual hash: $(sha256sum /sandbox/.openclaw/openclaw.json)" >&2 return 1 fi } @@ -96,8 +162,8 @@ PYTOKEN remote_url="${remote_url}#token=${token}" fi - echo "[gateway] Local UI: ${local_url}" - echo "[gateway] Remote UI: ${remote_url}" + echo "[gateway] Local UI: ${local_url}" >&2 + echo "[gateway] Remote UI: ${remote_url}" >&2 } start_auto_pair() { @@ -118,6 +184,13 @@ OPENCLAW = os.environ.get('OPENCLAW_BIN', 'openclaw') DEADLINE = time.time() + 600 QUIET_POLLS = 0 APPROVED = 0 +HANDLED = set() # Track rejected/approved requestIds to avoid reprocessing +# SECURITY NOTE: clientId/clientMode are client-supplied and spoofable +# (the gateway stores connectParams.client.id verbatim). This allowlist +# is defense-in-depth, not a trust boundary. PR #690 adds one-shot exit, +# timeout reduction, and token cleanup for a more comprehensive fix. +ALLOWED_CLIENTS = {'openclaw-control-ui'} +ALLOWED_MODES = {'webchat'} def run(*args): proc = subprocess.run(args, capture_output=True, text=True) @@ -141,13 +214,22 @@ while time.time() < DEADLINE: if pending: QUIET_POLLS = 0 for device in pending: - request_id = (device or {}).get('requestId') - if not request_id: + if not isinstance(device, dict): + continue + request_id = device.get('requestId') + if not request_id or request_id in HANDLED: + continue + client_id = device.get('clientId', '') + client_mode = device.get('clientMode', '') + if client_id not in ALLOWED_CLIENTS and client_mode not in ALLOWED_MODES: + HANDLED.add(request_id) + print(f'[auto-pair] rejected unknown client={client_id} mode={client_mode}') continue arc, aout, aerr = run(OPENCLAW, 'devices', 'approve', request_id, '--json') + HANDLED.add(request_id) if arc == 0: APPROVED += 1 - print(f'[auto-pair] approved request={request_id}') + print(f'[auto-pair] approved request={request_id} client={client_id}') elif aout or aerr: print(f'[auto-pair] approve failed request={request_id}: {(aerr or aout)[:400]}') time.sleep(1) @@ -167,12 +249,88 @@ while time.time() < DEADLINE: else: print(f'[auto-pair] watcher timed out approvals={APPROVED}') PYAUTOPAIR - echo "[gateway] auto-pair watcher launched (pid $!)" + echo "[gateway] auto-pair watcher launched (pid $!)" >&2 } +# ── Proxy environment ──────────────────────────────────────────── +# OpenShell injects HTTP_PROXY/HTTPS_PROXY/NO_PROXY into the sandbox, but its +# NO_PROXY is limited to 127.0.0.1,localhost,::1 — missing the gateway IP. +# The gateway IP itself must bypass the proxy to avoid proxy loops. +# +# Do NOT add inference.local here. OpenShell intentionally routes that hostname +# through the proxy path; bypassing the proxy forces a direct DNS lookup inside +# the sandbox, which breaks inference.local resolution. +# +# NEMOCLAW_PROXY_HOST / NEMOCLAW_PROXY_PORT can be overridden at sandbox +# creation time if the gateway IP or port changes in a future OpenShell release. +# Ref: https://github.com/NVIDIA/NemoClaw/issues/626 +PROXY_HOST="${NEMOCLAW_PROXY_HOST:-10.200.0.1}" +PROXY_PORT="${NEMOCLAW_PROXY_PORT:-3128}" +_PROXY_URL="http://${PROXY_HOST}:${PROXY_PORT}" +_NO_PROXY_VAL="localhost,127.0.0.1,::1,${PROXY_HOST}" +export HTTP_PROXY="$_PROXY_URL" +export HTTPS_PROXY="$_PROXY_URL" +export NO_PROXY="$_NO_PROXY_VAL" +export http_proxy="$_PROXY_URL" +export https_proxy="$_PROXY_URL" +export no_proxy="$_NO_PROXY_VAL" + +# OpenShell re-injects narrow NO_PROXY/no_proxy=127.0.0.1,localhost,::1 every +# time a user connects via `openshell sandbox connect`. The connect path spawns +# `/bin/bash -i` (interactive, non-login), which sources ~/.bashrc — NOT +# ~/.profile or /etc/profile.d/*. Write the full proxy config to ~/.bashrc so +# interactive sessions see the correct values. +# +# Both uppercase and lowercase variants are required: Node.js undici prefers +# lowercase (no_proxy) over uppercase (NO_PROXY) when both are set. +# curl/wget use uppercase. gRPC C-core uses lowercase. +# +# Also write to ~/.profile for login-shell paths (e.g. `sandbox create -- cmd` +# which spawns `bash -lc`). +# +# Idempotency: begin/end markers delimit the block so it can be replaced +# on restart if NEMOCLAW_PROXY_HOST/PORT change, without duplicating. +_PROXY_MARKER_BEGIN="# nemoclaw-proxy-config begin" +_PROXY_MARKER_END="# nemoclaw-proxy-config end" +_PROXY_SNIPPET="${_PROXY_MARKER_BEGIN} +export HTTP_PROXY=\"$_PROXY_URL\" +export HTTPS_PROXY=\"$_PROXY_URL\" +export NO_PROXY=\"$_NO_PROXY_VAL\" +export http_proxy=\"$_PROXY_URL\" +export https_proxy=\"$_PROXY_URL\" +export no_proxy=\"$_NO_PROXY_VAL\" +${_PROXY_MARKER_END}" + +if [ "$(id -u)" -eq 0 ]; then + _SANDBOX_HOME=$(getent passwd sandbox 2>/dev/null | cut -d: -f6) + _SANDBOX_HOME="${_SANDBOX_HOME:-/sandbox}" +else + _SANDBOX_HOME="${HOME:-/sandbox}" +fi + +_write_proxy_snippet() { + local target="$1" + if [ -f "$target" ] && grep -qF "$_PROXY_MARKER_BEGIN" "$target" 2>/dev/null; then + local tmp + tmp="$(mktemp)" + awk -v b="$_PROXY_MARKER_BEGIN" -v e="$_PROXY_MARKER_END" \ + '$0==b{s=1;next} $0==e{s=0;next} !s' "$target" >"$tmp" + printf '%s\n' "$_PROXY_SNIPPET" >>"$tmp" + cat "$tmp" >"$target" + rm -f "$tmp" + return 0 + fi + printf '\n%s\n' "$_PROXY_SNIPPET" >>"$target" +} + +if [ -w "$_SANDBOX_HOME" ]; then + _write_proxy_snippet "${_SANDBOX_HOME}/.bashrc" + _write_proxy_snippet "${_SANDBOX_HOME}/.profile" +fi + # ── Main ───────────────────────────────────────────────────────── -echo 'Setting up NemoClaw...' +echo 'Setting up NemoClaw...' >&2 [ -f .env ] && chmod 600 .env # ── Non-root fallback ────────────────────────────────────────── @@ -181,10 +339,11 @@ echo 'Setting up NemoClaw...' # separation and run everything as the current user (sandbox). # Gateway process isolation is not available in this mode. if [ "$(id -u)" -ne 0 ]; then - echo "[gateway] Running as non-root (uid=$(id -u)) — privilege separation disabled" + echo "[gateway] Running as non-root (uid=$(id -u)) — privilege separation disabled" >&2 export HOME=/sandbox if ! verify_config_integrity; then - echo "[SECURITY WARNING] Config integrity check failed — proceeding anyway (non-root mode)" + echo "[SECURITY] Config integrity check failed — refusing to start (non-root mode)" >&2 + exit 1 fi write_auth_profile @@ -204,7 +363,7 @@ if [ "$(id -u)" -ne 0 ]; then # Start gateway in background, auto-pair, then wait nohup "$OPENCLAW" gateway run >/tmp/gateway.log 2>&1 & GATEWAY_PID=$! - echo "[gateway] openclaw gateway launched (pid $GATEWAY_PID)" + echo "[gateway] openclaw gateway launched (pid $GATEWAY_PID)" >&2 start_auto_pair print_dashboard_urls wait "$GATEWAY_PID" @@ -242,18 +401,31 @@ for entry in /sandbox/.openclaw/*; do target="$(readlink -f "$entry" 2>/dev/null || true)" expected="/sandbox/.openclaw-data/$name" if [ "$target" != "$expected" ]; then - echo "[SECURITY] Symlink $entry points to unexpected target: $target (expected $expected)" + echo "[SECURITY] Symlink $entry points to unexpected target: $target (expected $expected)" >&2 exit 1 fi done +# Lock .openclaw directory after symlink validation: set the immutable flag +# so symlinks cannot be swapped at runtime even if DAC or Landlock are +# bypassed. chattr requires cap_linux_immutable which the entrypoint has +# as root; the sandbox user cannot remove the flag. +# Ref: https://github.com/NVIDIA/NemoClaw/issues/1019 +if command -v chattr >/dev/null 2>&1; then + chattr +i /sandbox/.openclaw 2>/dev/null || true + for entry in /sandbox/.openclaw/*; do + [ -L "$entry" ] || continue + chattr +i "$entry" 2>/dev/null || true + done +fi + # Start the gateway as the 'gateway' user. # SECURITY: The sandbox user cannot kill this process because it runs # under a different UID. The fake-HOME attack no longer works because # the agent cannot restart the gateway with a tampered config. nohup gosu gateway "$OPENCLAW" gateway run >/tmp/gateway.log 2>&1 & GATEWAY_PID=$! -echo "[gateway] openclaw gateway launched as 'gateway' user (pid $GATEWAY_PID)" +echo "[gateway] openclaw gateway launched as 'gateway' user (pid $GATEWAY_PID)" >&2 start_auto_pair print_dashboard_urls diff --git a/scripts/setup-dns-proxy.sh b/scripts/setup-dns-proxy.sh new file mode 100755 index 000000000..77e77ecfb --- /dev/null +++ b/scripts/setup-dns-proxy.sh @@ -0,0 +1,249 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Set up a DNS forwarder inside the sandbox pod so the isolated sandbox +# network namespace can resolve hostnames. +# +# Problem: The sandbox runs in an isolated namespace (10.200.0.0/24) +# where all non-proxy traffic is rejected by iptables. DNS (UDP:53) +# is blocked, causing getaddrinfo EAI_AGAIN for every outbound request. +# +# Fix (three steps): +# 1. Run a Python DNS forwarder on the pod-side veth gateway IP +# (10.200.0.1:53), forwarding to the real CoreDNS pod IP. +# 2. Add an iptables rule in the sandbox namespace to allow UDP +# to the gateway on port 53 (the only non-proxy exception). +# 3. Update the sandbox's /etc/resolv.conf to point to 10.200.0.1. +# +# Requires: sandbox must be in Ready state. Run after sandbox creation. +# +# Usage: ./scripts/setup-dns-proxy.sh [gateway-name] + +set -euo pipefail + +GATEWAY_NAME="${1:-}" +SANDBOX_NAME="${2:-}" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=./lib/runtime.sh +. "$SCRIPT_DIR/lib/runtime.sh" + +if [ -z "$SANDBOX_NAME" ]; then + echo "Usage: $0 [gateway-name] " + exit 1 +fi + +# ── Find the gateway container ────────────────────────────────────── + +if [ -z "${DOCKER_HOST:-}" ]; then + if docker_host="$(detect_docker_host)"; then + export DOCKER_HOST="$docker_host" + fi +fi + +CLUSTERS="$(docker ps --filter "name=openshell-cluster" --format '{{.Names}}' 2>/dev/null || true)" +CLUSTER="$(select_openshell_cluster_container "$GATEWAY_NAME" "$CLUSTERS" || true)" + +if [ -z "$CLUSTER" ]; then + if [ -n "$GATEWAY_NAME" ]; then + echo "WARNING: Could not find gateway container for '$GATEWAY_NAME'. DNS proxy not installed." + else + echo "WARNING: Could not find any openshell cluster container. DNS proxy not installed." + fi + exit 1 +fi + +# ── Helper: kubectl via gateway ───────────────────────────────────── + +kctl() { + docker exec "$CLUSTER" kubectl "$@" +} + +# ── Discover CoreDNS pod IP ───────────────────────────────────────── +# +# Forward to the real CoreDNS pod (not 8.8.8.8) so k8s-internal names +# like openshell-0.openshell.svc.cluster.local still resolve. CoreDNS +# handles both k8s names (kubernetes plugin) and external names +# (forward plugin, patched by fix-coredns.sh). + +DNS_UPSTREAM="$(kctl get endpoints kube-dns \ + -n kube-system -o jsonpath='{.subsets[0].addresses[0].ip}' 2>/dev/null || true)" + +if [ -z "$DNS_UPSTREAM" ]; then + echo "WARNING: Could not discover CoreDNS pod IP. Falling back to 8.8.8.8." + echo "WARNING: k8s-internal names (inference.local routing) will NOT work." + DNS_UPSTREAM="8.8.8.8" +fi + +# ── Find the sandbox pod ──────────────────────────────────────────── + +POD="$(kctl get pods -n openshell -o name 2>/dev/null \ + | grep -F -- "$SANDBOX_NAME" | head -1 | sed 's|pod/||' || true)" + +if [ -z "$POD" ]; then + echo "WARNING: Could not find pod for sandbox '$SANDBOX_NAME'. DNS proxy not installed." + exit 1 +fi + +# ── Discover the pod-side veth gateway IP ─────────────────────────── +# +# The sandbox connects to the pod via a veth pair. The pod side is +# typically 10.200.0.1. The forwarder must listen on this IP so +# packets from the sandbox (10.200.0.2) can reach it. + +VETH_GW="$(kctl exec -n openshell "$POD" -- sh -c \ + "ip addr show | grep 'inet 10\\.200\\.0\\.' | awk '{print \$2}' | cut -d/ -f1" \ + 2>/dev/null || true)" +VETH_GW="${VETH_GW:-10.200.0.1}" + +echo "Setting up DNS proxy in pod '$POD' (${VETH_GW}:53 -> ${DNS_UPSTREAM})..." + +# ── Step 1: Write DNS forwarder to the pod ────────────────────────── + +kctl exec -n openshell "$POD" -- sh -c "cat > /tmp/dns-proxy.py << 'DNSPROXY' +import socket, threading, os, sys + +UPSTREAM = (sys.argv[1] if len(sys.argv) > 1 else '8.8.8.8', 53) +BIND_IP = sys.argv[2] if len(sys.argv) > 2 else '0.0.0.0' + +sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +sock.bind((BIND_IP, 53)) + +with open('/tmp/dns-proxy.pid', 'w') as pf: + pf.write(str(os.getpid())) + +msg = 'dns-proxy: {}:53 -> {}:{} pid={}'.format(BIND_IP, UPSTREAM[0], UPSTREAM[1], os.getpid()) +print(msg, flush=True) +with open('/tmp/dns-proxy.log', 'w') as log: + log.write(msg + '\n') + +def forward(data, addr): + try: + f = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + f.settimeout(5) + f.sendto(data, UPSTREAM) + r, _ = f.recvfrom(4096) + sock.sendto(r, addr) + f.close() + except Exception: + pass + +while True: + d, a = sock.recvfrom(4096) + threading.Thread(target=forward, args=(d, a), daemon=True).start() +DNSPROXY" + +# ── Step 2: Kill any existing DNS proxy ───────────────────────────── + +OLD_PID="$(kctl exec -n openshell "$POD" -- cat /tmp/dns-proxy.pid 2>/dev/null || true)" +if [ -n "$OLD_PID" ]; then + kctl exec -n openshell "$POD" -- kill "$OLD_PID" 2>/dev/null || true + sleep 1 +fi + +# ── Step 3: Launch forwarder on pod-side veth gateway ─────────────── +# +# Use kubectl exec with nohup to start the forwarder as a background +# process inside the pod. This avoids the nsenter PID namespace +# mismatch that caused PR #732's launch to silently fail. +# +# Bind on the pod-side veth IP so the sandbox namespace can reach it +# once the iptables UDP exception is in place. + +kctl exec -n openshell "$POD" -- \ + sh -c "nohup python3 -u /tmp/dns-proxy.py '${DNS_UPSTREAM}' '${VETH_GW}' \ + > /tmp/dns-proxy.log 2>&1 &" + +sleep 2 + +# ── Step 4: Allow UDP DNS in sandbox iptables ─────────────────────── +# +# OpenShell's sandbox network policy rejects all non-proxy traffic +# (only TCP to 10.200.0.1:3128 is allowed). Insert a rule at the top +# of the OUTPUT chain to allow UDP to the gateway on port 53. + +SANDBOX_NS="$(kctl exec -n openshell "$POD" -- sh -c \ + "ls /run/netns/ 2>/dev/null | grep sandbox | head -1" 2>/dev/null || true)" + +if [ -n "$SANDBOX_NS" ]; then + kctl exec -n openshell "$POD" -- \ + ip netns exec "$SANDBOX_NS" \ + iptables -C OUTPUT -p udp -d "$VETH_GW" --dport 53 -j ACCEPT 2>/dev/null \ + || kctl exec -n openshell "$POD" -- \ + ip netns exec "$SANDBOX_NS" \ + iptables -I OUTPUT 1 -p udp -d "$VETH_GW" --dport 53 -j ACCEPT + + # ── Step 5: Update sandbox resolv.conf ──────────────────────────── + kctl exec -n openshell "$POD" -- \ + ip netns exec "$SANDBOX_NS" sh -c " + printf 'nameserver ${VETH_GW}\noptions ndots:5\n' > /etc/resolv.conf + " +else + echo "WARNING: Could not find sandbox network namespace. DNS may not work." +fi + +# ── Step 6: Runtime verification ───────────────────────────────────── +# +# Verify all three layers of the DNS bridge actually work, not just +# that the forwarder process started. This catches silent failures that +# static checks miss. + +VERIFY_PASS=0 +VERIFY_FAIL=0 + +# 6a. Forwarder process running +PID="$(kctl exec -n openshell "$POD" -- cat /tmp/dns-proxy.pid 2>/dev/null || true)" +LOG="$(kctl exec -n openshell "$POD" -- cat /tmp/dns-proxy.log 2>/dev/null || true)" + +if [ -n "$PID" ] && echo "$LOG" | grep -q "dns-proxy:"; then + echo " [PASS] DNS forwarder running (pid=$PID): $LOG" + VERIFY_PASS=$((VERIFY_PASS + 1)) +else + echo " [FAIL] DNS forwarder not running. PID=${PID:-none} Log: ${LOG:-empty}" + VERIFY_FAIL=$((VERIFY_FAIL + 1)) +fi + +# 6b-6d run inside sandbox namespace (require SANDBOX_NS) +if [ -n "$SANDBOX_NS" ]; then + sb_exec() { + kctl exec -n openshell "$POD" -- ip netns exec "$SANDBOX_NS" "$@" + } + + # 6b. resolv.conf points to the veth gateway + RESOLV="$(sb_exec cat /etc/resolv.conf 2>/dev/null || true)" + if echo "$RESOLV" | grep -q "nameserver ${VETH_GW}"; then + echo " [PASS] resolv.conf -> nameserver ${VETH_GW}" + VERIFY_PASS=$((VERIFY_PASS + 1)) + else + echo " [FAIL] resolv.conf does not point to ${VETH_GW}: ${RESOLV}" + VERIFY_FAIL=$((VERIFY_FAIL + 1)) + fi + + # 6c. iptables UDP DNS rule present + if sb_exec iptables -C OUTPUT -p udp -d "$VETH_GW" --dport 53 -j ACCEPT 2>/dev/null; then + echo " [PASS] iptables: UDP ${VETH_GW}:53 ACCEPT rule present" + VERIFY_PASS=$((VERIFY_PASS + 1)) + else + echo " [FAIL] iptables: UDP DNS ACCEPT rule missing" + VERIFY_FAIL=$((VERIFY_FAIL + 1)) + fi + + # 6d. Actual DNS resolution from sandbox (getent hosts) + DNS_RESULT="$(sb_exec getent hosts github.com 2>/dev/null || true)" + if [ -n "$DNS_RESULT" ]; then + echo " [PASS] getent hosts github.com -> ${DNS_RESULT}" + VERIFY_PASS=$((VERIFY_PASS + 1)) + else + echo " [FAIL] getent hosts github.com returned empty (DNS not resolving)" + VERIFY_FAIL=$((VERIFY_FAIL + 1)) + fi +else + echo " [SKIP] Sandbox namespace not found; cannot verify resolv.conf, iptables, or DNS" +fi + +echo " DNS verification: ${VERIFY_PASS} passed, ${VERIFY_FAIL} failed" +if [ "$VERIFY_FAIL" -gt 0 ]; then + echo "WARNING: DNS setup incomplete. Sandbox DNS resolution may not work. See issue #626." +fi diff --git a/scripts/setup-spark.sh b/scripts/setup-spark.sh index 9ab3ed80a..6e4204258 100755 --- a/scripts/setup-spark.sh +++ b/scripts/setup-spark.sh @@ -4,20 +4,16 @@ # # NemoClaw setup for DGX Spark devices. # -# Spark ships Ubuntu 24.04 (cgroup v2) + Docker 28.x but no k3s. -# OpenShell's gateway starts k3s inside a Docker container, which -# needs cgroup host namespace access. This script configures Docker -# for that. +# Ensures the current user is in the docker group so NemoClaw can +# manage containers without sudo. # # Usage: -# sudo nemoclaw setup-spark -# # or directly: # sudo bash scripts/setup-spark.sh +# # or via curl: +# curl -fsSL https://raw.githubusercontent.com/NVIDIA/NemoClaw/main/scripts/setup-spark.sh | sudo bash # # What it does: # 1. Adds current user to docker group (avoids sudo for everything else) -# 2. Configures Docker daemon for cgroupns=host (k3s-in-Docker on cgroup v2) -# 3. Restarts Docker set -euo pipefail @@ -59,84 +55,15 @@ if [ -n "$REAL_USER" ]; then else info "Adding '$REAL_USER' to docker group..." usermod -aG docker "$REAL_USER" - info "Added. Group will take effect on next login (or use 'newgrp docker')." + DOCKER_GROUP_ADDED=true fi fi -# ── 2. Docker cgroup namespace ──────────────────────────────────── -# -# Spark runs cgroup v2 (Ubuntu 24.04). OpenShell's gateway embeds -# k3s in a Docker container, which needs --cgroupns=host to manage -# cgroup hierarchies. Without this, kubelet fails with: -# "openat2 /sys/fs/cgroup/kubepods/pids.max: no" -# -# Setting default-cgroupns-mode=host in daemon.json makes all -# containers use the host cgroup namespace. This is safe — it's -# the Docker default on cgroup v1 hosts anyway. - -DAEMON_JSON="/etc/docker/daemon.json" -NEEDS_RESTART=false +# ── 2. Next steps ───────────────────────────────────────────────── -if [ -f "$DAEMON_JSON" ]; then - # Check if already configured - if grep -q '"default-cgroupns-mode"' "$DAEMON_JSON" 2>/dev/null; then - CURRENT_MODE=$(python3 -c "import json; print(json.load(open('$DAEMON_JSON')).get('default-cgroupns-mode',''))" 2>/dev/null || echo "") - if [ "$CURRENT_MODE" = "host" ]; then - info "Docker daemon already configured for cgroupns=host" - else - info "Updating Docker daemon cgroupns mode to 'host'..." - python3 -c " -import json -with open('$DAEMON_JSON') as f: - d = json.load(f) -d['default-cgroupns-mode'] = 'host' -with open('$DAEMON_JSON', 'w') as f: - json.dump(d, f, indent=2) -" - NEEDS_RESTART=true - fi - else - info "Adding cgroupns=host to Docker daemon config..." - python3 -c " -import json -try: - with open('$DAEMON_JSON') as f: - d = json.load(f) -except: - d = {} -d['default-cgroupns-mode'] = 'host' -with open('$DAEMON_JSON', 'w') as f: - json.dump(d, f, indent=2) -" - NEEDS_RESTART=true - fi +echo "" +if [ "${DOCKER_GROUP_ADDED:-}" = true ]; then + warn "Docker group was just added. You must open a new terminal (or run 'newgrp docker') before continuing." else - info "Creating Docker daemon config with cgroupns=host..." - mkdir -p "$(dirname "$DAEMON_JSON")" - echo '{ "default-cgroupns-mode": "host" }' >"$DAEMON_JSON" - NEEDS_RESTART=true + info "DGX Spark Docker configuration complete." fi - -# ── 3. Restart Docker if needed ─────────────────────────────────── - -if [ "$NEEDS_RESTART" = true ]; then - info "Restarting Docker daemon..." - systemctl restart docker - # Wait for Docker to be ready - for i in 1 2 3 4 5 6 7 8 9 10; do - if docker info >/dev/null 2>&1; then - break - fi - [ "$i" -eq 10 ] && fail "Docker didn't come back after restart. Check 'systemctl status docker'." - sleep 2 - done - info "Docker restarted with cgroupns=host" -fi - -# ── 4. Run normal setup ────────────────────────────────────────── - -echo "" -info "DGX Spark Docker configuration complete." -info "" -info "Next step: run 'nemoclaw onboard' to set up your sandbox." -info " nemoclaw onboard" diff --git a/scripts/setup.sh b/scripts/setup.sh deleted file mode 100755 index 978c9d8ae..000000000 --- a/scripts/setup.sh +++ /dev/null @@ -1,242 +0,0 @@ -#!/usr/bin/env bash -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# -# NemoClaw setup — run this on the HOST to set up everything. -# -# Prerequisites: -# - Docker running (Colima, Docker Desktop, or native) -# - openshell CLI installed (pip install openshell @ git+https://github.com/NVIDIA/OpenShell.git) -# - NVIDIA_API_KEY set in environment (from build.nvidia.com) -# -# Usage: -# export NVIDIA_API_KEY=nvapi-... -# ./scripts/setup.sh [sandbox-name] -# -# What it does: -# 1. Starts an OpenShell gateway (or reuses existing) -# 2. Fixes CoreDNS for Colima environments -# 3. Creates nvidia-nim provider (build.nvidia.com) -# 4. Creates vllm-local provider (if vLLM is running) -# 5. Sets inference route to nvidia-nim by default -# 6. Builds and creates the NemoClaw sandbox -# 7. Prints next steps - -set -euo pipefail - -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -# shellcheck source=./lib/runtime.sh -. "$SCRIPT_DIR/lib/runtime.sh" - -info() { echo -e "${GREEN}>>>${NC} $1"; } -warn() { echo -e "${YELLOW}>>>${NC} $1"; } -fail() { - echo -e "${RED}>>>${NC} $1" - exit 1 -} - -upsert_provider() { - local name="$1" - local type="$2" - local credential="$3" - local config="$4" - - if openshell provider create --name "$name" --type "$type" \ - --credential "$credential" \ - --config "$config" 2>&1 | grep -q "AlreadyExists"; then - openshell provider update "$name" \ - --credential "$credential" \ - --config "$config" >/dev/null - info "Updated $name provider" - else - info "Created $name provider" - fi -} - -# Resolve DOCKER_HOST for macOS user-scoped runtimes when needed. -ORIGINAL_DOCKER_HOST="${DOCKER_HOST:-}" -if docker_host="$(detect_docker_host)"; then - export DOCKER_HOST="$docker_host" - if [ -n "$ORIGINAL_DOCKER_HOST" ]; then - warn "Using DOCKER_HOST from environment: $docker_host" - else - case "$(docker_host_runtime "$docker_host" || true)" in - colima) - warn "Using Colima Docker socket: ${docker_host#unix://}" - ;; - docker-desktop) - warn "Using Docker Desktop socket: ${docker_host#unix://}" - ;; - custom) - warn "Using Docker host: $docker_host" - ;; - esac - fi -fi - -# Check prerequisites -command -v openshell >/dev/null || fail "openshell CLI not found. Install the binary from https://github.com/NVIDIA/OpenShell/releases" -command -v docker >/dev/null || fail "docker not found" -[ -n "${NVIDIA_API_KEY:-}" ] || fail "NVIDIA_API_KEY not set. Get one from build.nvidia.com" - -CONTAINER_RUNTIME="$(infer_container_runtime_from_info "$(docker info 2>/dev/null || true)")" -if is_unsupported_macos_runtime "$(uname -s)" "$CONTAINER_RUNTIME"; then - fail "Podman on macOS is not supported yet. NemoClaw currently depends on OpenShell support for Podman on macOS. Use Colima or Docker Desktop instead." -fi -if [ "$CONTAINER_RUNTIME" != "unknown" ]; then - info "Container runtime: $CONTAINER_RUNTIME" -fi -SANDBOX_NAME="${1:-nemoclaw}" -info "Using sandbox name: ${SANDBOX_NAME}" - -OPEN_SHELL_VERSION_RAW="$(openshell -V 2>/dev/null || true)" -OPEN_SHELL_VERSION_LOWER="${OPEN_SHELL_VERSION_RAW,,}" -if [[ "$OPEN_SHELL_VERSION_LOWER" =~ openshell[[:space:]]+([0-9]+\.[0-9]+\.[0-9]+) ]]; then - export IMAGE_TAG="${BASH_REMATCH[1]}" - export OPENSHELL_CLUSTER_IMAGE="ghcr.io/nvidia/openshell/cluster:${BASH_REMATCH[1]}" - info "Using pinned OpenShell gateway image: ${OPENSHELL_CLUSTER_IMAGE}" -elif [[ -n "$OPEN_SHELL_VERSION_RAW" ]]; then - warn "Could not parse openshell version from 'openshell -V': ${OPEN_SHELL_VERSION_RAW}" - warn "Skipping OpenShell gateway image pinning." -fi - -# 1. Gateway — always start fresh to avoid stale state -info "Starting OpenShell gateway..." -openshell gateway destroy -g nemoclaw >/dev/null 2>&1 || true -GATEWAY_ARGS=(--name nemoclaw) -command -v nvidia-smi >/dev/null 2>&1 && GATEWAY_ARGS+=(--gpu) -openshell gateway start "${GATEWAY_ARGS[@]}" 2>&1 | grep -E "Gateway|✓|Error|error" || true - -# Verify gateway is actually healthy (may need a moment after start) -for i in 1 2 3 4 5; do - if openshell status 2>&1 | grep -q "Connected"; then - break - fi - [ "$i" -eq 5 ] && fail "Gateway failed to start. Check 'openshell gateway info' and Docker logs." - sleep 2 -done -info "Gateway is healthy" - -# 2. CoreDNS fix (Colima only) -if [ "$CONTAINER_RUNTIME" = "colima" ]; then - info "Patching CoreDNS for Colima..." - bash "$SCRIPT_DIR/fix-coredns.sh" nemoclaw 2>&1 || warn "CoreDNS patch failed (may not be needed)" -fi - -# 3. Providers -info "Setting up inference providers..." - -# nvidia-nim (build.nvidia.com) -# Use env-name-only form so openshell reads the value from the environment -# internally — the literal key value never appears in the process argument list. -upsert_provider \ - "nvidia-nim" \ - "openai" \ - "NVIDIA_API_KEY" \ - "OPENAI_BASE_URL=https://integrate.api.nvidia.com/v1" - -# vllm-local (if vLLM is installed or running) -if check_local_provider_health "vllm-local" || python3 -c "import vllm" 2>/dev/null; then - VLLM_LOCAL_BASE_URL="$(get_local_provider_base_url "vllm-local")" - upsert_provider \ - "vllm-local" \ - "openai" \ - "OPENAI_API_KEY=dummy" \ - "OPENAI_BASE_URL=$VLLM_LOCAL_BASE_URL" -fi - -# 4a. Ollama (macOS local inference) -if [ "$(uname -s)" = "Darwin" ]; then - if ! command -v ollama >/dev/null 2>&1; then - info "Installing Ollama..." - brew install ollama 2>/dev/null || warn "Ollama install failed (brew required). Install manually: https://ollama.com" - fi - if command -v ollama >/dev/null 2>&1; then - # Start Ollama service if not running - if ! check_local_provider_health "ollama-local"; then - info "Starting Ollama service..." - OLLAMA_HOST=0.0.0.0:11434 ollama serve >/dev/null 2>&1 & - sleep 2 - fi - OLLAMA_LOCAL_BASE_URL="$(get_local_provider_base_url "ollama-local")" - upsert_provider \ - "ollama-local" \ - "openai" \ - "OPENAI_API_KEY=ollama" \ - "OPENAI_BASE_URL=$OLLAMA_LOCAL_BASE_URL" - fi -fi - -# 4b. Inference route — default to nvidia-nim -info "Setting inference route to nvidia-nim / Nemotron 3 Super..." -openshell inference set --no-verify --provider nvidia-nim --model nvidia/nemotron-3-super-120b-a12b >/dev/null 2>&1 - -# 5. Build and create sandbox -info "Deleting old ${SANDBOX_NAME} sandbox (if any)..." -openshell sandbox delete "$SANDBOX_NAME" >/dev/null 2>&1 || true - -info "Building and creating NemoClaw sandbox (this takes a few minutes on first run)..." - -# Stage a clean build context (openshell doesn't honor .dockerignore) -BUILD_CTX="$(mktemp -d)" -cp "$REPO_DIR/Dockerfile" "$BUILD_CTX/" -cp -r "$REPO_DIR/nemoclaw" "$BUILD_CTX/nemoclaw" -cp -r "$REPO_DIR/nemoclaw-blueprint" "$BUILD_CTX/nemoclaw-blueprint" -cp -r "$REPO_DIR/scripts" "$BUILD_CTX/scripts" -rm -rf "$BUILD_CTX/nemoclaw/node_modules" - -# Capture full output to a temp file so we can filter for display but still -# detect failures. The raw log is kept on failure for debugging. -CREATE_LOG=$(mktemp /tmp/nemoclaw-create-XXXXXX.log) -set +e -# NVIDIA_API_KEY is NOT passed into the sandbox. Inference is proxied through -# the OpenShell gateway which injects the stored credential server-side. -openshell sandbox create --from "$BUILD_CTX/Dockerfile" --name "$SANDBOX_NAME" \ - --provider nvidia-nim \ - >"$CREATE_LOG" 2>&1 -CREATE_RC=$? -set -e -rm -rf "$BUILD_CTX" - -# Show progress lines (filter apt noise and env var dumps that contain NVIDIA_API_KEY) -grep -E "^ (Step |Building |Built |Pushing |\[progress\]|Successfully |Created sandbox|Image )|✓" "$CREATE_LOG" || true - -if [ "$CREATE_RC" != "0" ]; then - echo "" - warn "Last 20 lines of build output:" - tail -20 "$CREATE_LOG" | grep -v "NVIDIA_API_KEY" - echo "" - fail "Sandbox creation failed (exit $CREATE_RC). Full log: $CREATE_LOG" -fi -rm -f "$CREATE_LOG" - -# Verify sandbox is Ready (not just that a record exists) -# Strip ANSI color codes before checking phase -SANDBOX_LINE=$(openshell sandbox list 2>&1 | sed 's/\x1b\[[0-9;]*m//g' | awk -v name="$SANDBOX_NAME" '$1 == name { print; exit }') -if ! echo "$SANDBOX_LINE" | grep -q "Ready"; then - SANDBOX_PHASE=$(echo "$SANDBOX_LINE" | awk '{print $NF}') - echo "" - warn "Sandbox phase: ${SANDBOX_PHASE:-unknown}" - # Check for common failure modes - SB_DETAIL=$(openshell sandbox get "$SANDBOX_NAME" 2>&1 || true) - if echo "$SB_DETAIL" | grep -qi "ImagePull\|ErrImagePull\|image.*not found"; then - warn "Image pull failure detected. The sandbox image was built inside the" - warn "gateway but k3s can't find it. This is a known openshell issue." - warn "Workaround: run 'openshell gateway destroy && openshell gateway start'" - warn "and re-run this script." - fi - fail "Sandbox created but not Ready (phase: ${SANDBOX_PHASE:-unknown}). Check 'openshell sandbox get ${SANDBOX_NAME}'." -fi - -# 6. Done -echo "" -info "Setup complete!" -echo "" -echo " openclaw agent --agent main --local -m 'how many rs are there in strawberry?' --session-id s1" -echo "" diff --git a/scripts/start-services.sh b/scripts/start-services.sh index 303caf696..0c64d1341 100755 --- a/scripts/start-services.sh +++ b/scripts/start-services.sh @@ -123,15 +123,25 @@ do_stop() { } do_start() { - [ -n "${NVIDIA_API_KEY:-}" ] || fail "NVIDIA_API_KEY required" - if [ -z "${TELEGRAM_BOT_TOKEN:-}" ]; then warn "TELEGRAM_BOT_TOKEN not set — Telegram bridge will not start." warn "Create a bot via @BotFather on Telegram and set the token." + elif [ -z "${NVIDIA_API_KEY:-}" ]; then + warn "NVIDIA_API_KEY not set — Telegram bridge will not start." + warn "Set NVIDIA_API_KEY if you want Telegram requests to reach inference." fi command -v node >/dev/null || fail "node not found. Install Node.js first." + # WSL2 ships with broken IPv6 routing. Node.js resolves dual-stack DNS results + # and tries IPv6 first (ENETUNREACH) then IPv4 (ETIMEDOUT), causing bridge + # connections to api.telegram.org and gateway.discord.gg to fail from the host. + # Force IPv4-first DNS result ordering for all bridge Node.js processes. + if [ -n "${WSL_DISTRO_NAME:-}" ] || [ -n "${WSL_INTEROP:-}" ] || grep -qi microsoft /proc/version 2>/dev/null; then + export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--dns-result-order=ipv4first" + info "WSL2 detected — setting --dns-result-order=ipv4first for Node.js bridge processes" + fi + # Verify sandbox is running if command -v openshell >/dev/null 2>&1; then if ! openshell sandbox list 2>&1 | grep -q "Ready"; then @@ -142,7 +152,7 @@ do_start() { mkdir -p "$PIDDIR" # Telegram bridge (only if token provided) - if [ -n "${TELEGRAM_BOT_TOKEN:-}" ]; then + if [ -n "${TELEGRAM_BOT_TOKEN:-}" ] && [ -n "${NVIDIA_API_KEY:-}" ]; then SANDBOX_NAME="$SANDBOX_NAME" start_service telegram-bridge \ node "$REPO_DIR/scripts/telegram-bridge.js" fi diff --git a/scripts/telegram-bridge.js b/scripts/telegram-bridge.js index c51a5529a..96a29fd88 100755 --- a/scripts/telegram-bridge.js +++ b/scripts/telegram-bridge.js @@ -20,6 +20,7 @@ const https = require("https"); const { execFileSync, spawn } = require("child_process"); const { resolveOpenshell } = require("../bin/lib/resolve-openshell"); const { shellQuote, validateName } = require("../bin/lib/runner"); +const { parseAllowedChatIds, isChatAllowed } = require("../bin/lib/chat-filter"); const OPENSHELL = resolveOpenshell(); if (!OPENSHELL) { @@ -31,9 +32,7 @@ const TOKEN = process.env.TELEGRAM_BOT_TOKEN; const API_KEY = process.env.NVIDIA_API_KEY; const SANDBOX = process.env.SANDBOX_NAME || "nemoclaw"; try { validateName(SANDBOX, "SANDBOX_NAME"); } catch (e) { console.error(e.message); process.exit(1); } -const ALLOWED_CHATS = process.env.ALLOWED_CHAT_IDS - ? process.env.ALLOWED_CHAT_IDS.split(",").map((s) => s.trim()) - : null; +const ALLOWED_CHATS = parseAllowedChatIds(process.env.ALLOWED_CHAT_IDS); if (!TOKEN) { console.error("TELEGRAM_BOT_TOKEN required"); process.exit(1); } if (!API_KEY) { console.error("NVIDIA_API_KEY required"); process.exit(1); } @@ -41,6 +40,10 @@ if (!API_KEY) { console.error("NVIDIA_API_KEY required"); process.exit(1); } let offset = 0; const activeSessions = new Map(); // chatId → message history +const COOLDOWN_MS = 5000; +const lastMessageTime = new Map(); +const busyChats = new Set(); + // ── Telegram API helpers ────────────────────────────────────────── function tgApi(method, body) { @@ -170,7 +173,7 @@ async function poll() { const chatId = String(msg.chat.id); // Access control - if (ALLOWED_CHATS && !ALLOWED_CHATS.includes(chatId)) { + if (!isChatAllowed(ALLOWED_CHATS, chatId)) { console.log(`[ignored] chat ${chatId} not in allowed list`); continue; } @@ -198,6 +201,24 @@ async function poll() { continue; } + // Rate limiting: per-chat cooldown + const now = Date.now(); + const lastTime = lastMessageTime.get(chatId) || 0; + if (now - lastTime < COOLDOWN_MS) { + const wait = Math.ceil((COOLDOWN_MS - (now - lastTime)) / 1000); + await sendMessage(chatId, `Please wait ${wait}s before sending another message.`, msg.message_id); + continue; + } + + // Per-chat serialization: reject if this chat already has an active session + if (busyChats.has(chatId)) { + await sendMessage(chatId, "Still processing your previous message.", msg.message_id); + continue; + } + + lastMessageTime.set(chatId, now); + busyChats.add(chatId); + // Send typing indicator await sendTyping(chatId); @@ -212,6 +233,8 @@ async function poll() { } catch (err) { clearInterval(typingInterval); await sendMessage(chatId, `Error: ${err.message}`, msg.message_id); + } finally { + busyChats.delete(chatId); } } } @@ -219,8 +242,8 @@ async function poll() { console.error("Poll error:", err.message); } - // Continue polling - setTimeout(poll, 100); + // Continue polling (1s floor prevents tight-loop resource waste) + setTimeout(poll, 1000); } // ── Main ────────────────────────────────────────────────────────── diff --git a/scripts/walkthrough.sh b/scripts/walkthrough.sh index fe176220d..3a02ec638 100755 --- a/scripts/walkthrough.sh +++ b/scripts/walkthrough.sh @@ -13,7 +13,7 @@ # the TUI prompts the operator to approve or deny the request. # # Prerequisites: -# - NemoClaw setup complete (./scripts/setup.sh) +# - NemoClaw setup complete (nemoclaw onboard) # - NVIDIA_API_KEY in environment # # Suggested prompts that trigger the approval flow: diff --git a/spark-install.md b/spark-install.md index a976b7192..94cf265bf 100644 --- a/spark-install.md +++ b/spark-install.md @@ -2,30 +2,24 @@ > **WIP** — This page is actively being updated as we work through Spark installs. Expect changes. +This guide walks you through installing and running NemoClaw on an NVIDIA DGX Spark. DGX Spark ships with Ubuntu 24.04 and Docker pre-installed; the steps below handle the remaining Spark-specific configuration so you can get from zero to a working sandbox. + ## Prerequisites -- **Docker** (pre-installed, v28.x) -- **Node.js 22** (installed by the install.sh) -- **OpenShell CLI** (installed via the Quick Start steps below) -- **API key** for your chosen inference provider. The onboarding wizard prompts for provider and key during setup. For example, you need to provide an NVIDIA API key from [build.nvidia.com](https://build.nvidia.com) for NVIDIA Endpoints, or an OpenAI, Anthropic, or Gemini key for those corresponding providers. +Before starting, make sure you have: + +- **Docker** (pre-installed on DGX Spark, v28.x/29.x) +- **Node.js 22** (installed automatically by the NemoClaw installer) +- **OpenShell CLI** (installed automatically by the NemoClaw installer) +- **API key** (cloud inference only) — the onboarding wizard prompts for a provider and key during setup. For example, an NVIDIA API key from [build.nvidia.com](https://build.nvidia.com) for NVIDIA Endpoints, or an OpenAI, Anthropic, or Gemini key for those providers. **If you plan to use local inference with Ollama instead, no API key is needed** — see [Local Inference with Ollama](#local-inference-with-ollama) to set up Ollama before installing NemoClaw. ## Quick Start ```bash -# Install OpenShell: -curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh - -# Clone NemoClaw: -git clone https://github.com/NVIDIA/NemoClaw.git -cd NemoClaw +# Spark-specific setup (requires sudo) +curl -fsSL https://raw.githubusercontent.com/NVIDIA/NemoClaw/main/scripts/setup-spark.sh | sudo bash -# Spark-specific setup (For details see [What's Different on Spark](#whats-different-on-spark)) -sudo ./scripts/setup-spark.sh - -# Install NemoClaw using the NemoClaw/install.sh: -./install.sh - -# Alternatively, you can use the hosted install script: +# Install NemoClaw curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash ``` @@ -39,18 +33,20 @@ nemoclaw my-assistant connect openclaw agent --agent main --local -m "hello" --session-id test ``` -## Uninstall (perform this before re-installing) +## Uninstall + +To remove NemoClaw and start fresh (e.g., to switch inference providers): ```bash -# Uninstall NemoClaw (Remove OpenShell sandboxes, gateway, NemoClaw providers, related Docker containers, images, volumes and configs) +# Remove OpenShell sandboxes, gateway, NemoClaw providers, related Docker containers, images, volumes and configs nemoclaw uninstall ``` -## Setup Local Inference (Ollama) +## Local Inference with Ollama Use this to run inference locally on the DGX Spark's GPU instead of routing to cloud. -### Verify the NVIDIA Container Runtime +### 1. Verify the NVIDIA Container Runtime ```bash docker run --rm --runtime=nvidia --gpus all ubuntu nvidia-smi @@ -63,7 +59,7 @@ sudo nvidia-ctk runtime configure --runtime=docker sudo systemctl restart docker ``` -### Install Ollama +### 2. Install Ollama ```bash curl -fsSL https://ollama.com/install.sh | sh @@ -75,7 +71,7 @@ Verify it is running: curl http://localhost:11434 ``` -### Pull and Pre-load a Model +### 3. Pull and Pre-load a Model Download Nemotron 3 Super 120B (~87 GB; may take several minutes): @@ -90,7 +86,7 @@ ollama run nemotron-3-super:120b # type /bye to exit ``` -### Configure Ollama to Listen on All Interfaces +### 4. Configure Ollama to Listen on All Interfaces By default Ollama binds to `127.0.0.1`, which is not reachable from inside the sandbox container. Configure it to listen on all interfaces: @@ -110,20 +106,20 @@ Verify Ollama is listening on all interfaces: sudo ss -tlnp | grep 11434 ``` -### Install OpenShell and NemoClaw +### 5. Install (or Reinstall) NemoClaw with Local Inference + +If you have **not installed NemoClaw yet**, continue with the [Quick Start](#quick-start) steps above. When the onboarding wizard prompts for **Inference options**, select **Local Ollama** and choose the model you pulled. + +If NemoClaw is **already installed** with a cloud provider and you want to switch to local inference, uninstall and reinstall: ```bash -# If the OpenShell and NemoClaw are already installed, uninstall them. A fresh NemoClaw install will run onboard with local inference options. nemoclaw uninstall - -# Install OpenShell and NemoClaw -curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash ``` When prompted for **Inference options**, select **Local Ollama**, then select the model you pulled. -### Connect and Test +### 6. Connect and Test ```bash # Connect to the sandbox @@ -144,76 +140,85 @@ Then talk to the agent: openclaw agent --agent main --local -m "Which model and GPU are in use?" --session-id test ``` -## What's Different on Spark +## Troubleshooting -DGX Spark ships **Ubuntu 24.04 + Docker 28.x** but no k8s/k3s. OpenShell embeds k3s inside a Docker container, which hits two problems on Spark: +### Known Issues -### 1. Docker permissions +| Issue | Status | Workaround | +|-------|--------|------------| +| cgroup v2 kills k3s in Docker | Fixed in recent OpenShell versions | OpenShell sets `cgroupns=host` on the gateway container directly | +| Docker permission denied | Fixed in `setup-spark` | `usermod -aG docker` | +| CoreDNS CrashLoop after setup | Fixed in `fix-coredns.sh` | Uses container gateway IP, not 127.0.0.11 | +| Image pull failure (k3s can't find built image) | OpenShell bug | `openshell gateway destroy && openshell gateway start`, re-run setup | +| GPU passthrough | Untested on Spark | Should work with `--gpu` flag if NVIDIA Container Toolkit is configured | +| `pip install` fails with system packages | Known | Use a venv (recommended) or `--break-system-packages` (last resort, can break system tools) | +| Port 3000 conflict with AI Workbench | Known | AI Workbench Traefik proxy uses port 3000 (and 10000); use a different port for other services | +| Network policy blocks NVIDIA cloud API | By design | Ensure `integrate.api.nvidia.com` is in the sandbox network policy if using cloud inference | -```text -Error in the hyper legacy client: client error (Connect) - Permission denied (os error 13) +### Manual Setup (if setup-spark doesn't work) + +If `setup-spark.sh` fails, you can apply the fix it performs by hand: + +#### Fix Docker permissions + +```bash +sudo usermod -aG docker $USER +newgrp docker # or log out and back in ``` -**Cause**: Your user isn't in the `docker` group. -**Fix**: `setup-spark` runs `usermod -aG docker $USER`. You may need to log out and back in (or `newgrp docker`) for it to take effect. +## Technical Reference + +### Web Dashboard -### 2. cgroup v2 incompatibility +The OpenClaw gateway includes a built-in web UI. Access it at: ```text -K8s namespace not ready -openat2 /sys/fs/cgroup/kubepods/pids.max: no -Failed to start ContainerManager: failed to initialize top level QOS containers +http://127.0.0.1:18789/#token= ``` -**Cause**: Spark runs cgroup v2 (Ubuntu 24.04 default). OpenShell's gateway container starts k3s, which tries to create cgroup v1-style paths that don't exist. The fix is `--cgroupns=host` on the container, but OpenShell doesn't expose that flag. +Find your gateway token in `~/.openclaw/openclaw.json` under `gateway.auth.token` inside the sandbox. -**Fix**: `setup-spark` sets `"default-cgroupns-mode": "host"` in `/etc/docker/daemon.json` and restarts Docker. This makes all containers use the host cgroup namespace, which is what k3s needs. +> **Important**: Use `127.0.0.1` (not `localhost`) — the gateway's origin check requires an exact match. External dashboards like Mission Control cannot currently connect due to the gateway resetting `controlUi.allowedOrigins` on every config reload (see [openclaw#49950](https://github.com/openclaw/openclaw/issues/49950)). -## Manual Setup (if setup-spark doesn't work) +### NIM Compatibility on arm64 -### Fix Docker cgroup namespace +Some NIM containers (e.g., Nemotron-3-Super-120B-A12B) ship native arm64 images and run on the Spark. However, many NIM images are amd64-only and will fail with `exec format error`. Check the image architecture before pulling. For models without arm64 NIM support, consider using Ollama or [llama.cpp](https://github.com/ggml-org/llama.cpp) with GGUF models as alternatives. -```bash -# Check if you're on cgroup v2 -stat -fc %T /sys/fs/cgroup/ -# Expected: cgroup2fs +### What's Different on Spark -# Add cgroupns=host to Docker daemon config -sudo python3 -c " -import json, os -path = '/etc/docker/daemon.json' -d = json.load(open(path)) if os.path.exists(path) else {} -d['default-cgroupns-mode'] = 'host' -json.dump(d, open(path, 'w'), indent=2) -" +DGX Spark ships **Ubuntu 24.04 (Noble) + Docker 28.x/29.x** on **aarch64 (Grace CPU + GB10 GPU, 128 GB unified memory)** but no k8s/k3s. OpenShell embeds k3s inside a Docker container, which hits two problems on Spark: -# Restart Docker -sudo systemctl restart docker +#### Docker permissions + +```text +Error in the hyper legacy client: client error (Connect) + Permission denied (os error 13) ``` -### Fix Docker permissions +**Cause**: Your user isn't in the `docker` group. +**Fix**: `setup-spark` runs `usermod -aG docker $USER`. You may need to log out and back in (or `newgrp docker`) for it to take effect. -```bash -sudo usermod -aG docker $USER -newgrp docker # or log out and back in +#### cgroup v2 incompatibility (resolved) + +```text +K8s namespace not ready +openat2 /sys/fs/cgroup/kubepods/pids.max: no +Failed to start ContainerManager: failed to initialize top level QOS containers ``` -## Known Issues +**Cause**: Spark runs cgroup v2 (Ubuntu 24.04 default). OpenShell's gateway container starts k3s, which tries to create cgroup v1-style paths that don't exist without host cgroup namespace access. -| Issue | Status | Workaround | -|-------|--------|------------| -| cgroup v2 kills k3s in Docker | Fixed in `setup-spark` | `daemon.json` cgroupns=host | -| Docker permission denied | Fixed in `setup-spark` | `usermod -aG docker` | -| CoreDNS CrashLoop after setup | Fixed in `fix-coredns.sh` | Uses container gateway IP, not 127.0.0.11 | -| Image pull failure (k3s can't find built image) | OpenShell bug | `openshell gateway destroy && openshell gateway start`, re-run setup | -| GPU passthrough | Untested on Spark | Should work with `--gpu` flag if NVIDIA Container Toolkit is configured | +**Fix**: Recent OpenShell versions set `cgroupns=host` on the gateway container directly ([OpenShell PR #329](https://github.com/NVIDIA/OpenShell/pull/329)). No `daemon.json` workaround is needed. If you are on an older OpenShell version, upgrade with: + +```bash +curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh +``` -## Architecture Notes +### Architecture ```text -DGX Spark (Ubuntu 24.04, cgroup v2) - └── Docker (28.x, cgroupns=host) +DGX Spark (Ubuntu 24.04, aarch64, cgroup v2, 128 GB unified memory) + └── Docker (28.x/29.x) └── OpenShell gateway container └── k3s (embedded) └── nemoclaw sandbox pod diff --git a/src/lib/build-context.ts b/src/lib/build-context.ts new file mode 100644 index 000000000..2407f2ebb --- /dev/null +++ b/src/lib/build-context.ts @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Helpers for staging a Docker build context and classifying sandbox + * creation failures. + */ + +import fs from "node:fs"; +import path from "node:path"; + +import { classifySandboxCreateFailure } from "./validation"; + +const EXCLUDED_SEGMENTS = new Set([ + ".venv", + ".ruff_cache", + ".pytest_cache", + ".mypy_cache", + "__pycache__", + "node_modules", + ".git", +]); + +export function shouldIncludeBuildContextPath(sourceRoot: string, candidatePath: string): boolean { + const relative = path.relative(sourceRoot, candidatePath); + if (!relative || relative === "") return true; + + const segments = relative.split(path.sep); + const basename = path.basename(candidatePath); + + if (basename === ".DS_Store" || basename.startsWith("._")) { + return false; + } + + return !segments.some((segment) => EXCLUDED_SEGMENTS.has(segment)); +} + +export function copyBuildContextDir(sourceDir: string, destinationDir: string): void { + fs.cpSync(sourceDir, destinationDir, { + recursive: true, + filter: (candidatePath) => shouldIncludeBuildContextPath(sourceDir, candidatePath), + }); +} + +export function printSandboxCreateRecoveryHints(output = ""): void { + const failure = classifySandboxCreateFailure(output); + if (failure.kind === "image_transfer_timeout") { + console.error(" Hint: image upload into the OpenShell gateway timed out."); + console.error(" Recovery: nemoclaw onboard --resume"); + if (failure.uploadedToGateway) { + console.error( + " Progress reached the gateway upload stage, so resume may be able to reuse existing gateway state.", + ); + } + console.error(" If this repeats, check Docker memory and retry on a host with more RAM."); + return; + } + if (failure.kind === "image_transfer_reset") { + console.error(" Hint: the image push/import stream was interrupted."); + console.error(" Recovery: nemoclaw onboard --resume"); + if (failure.uploadedToGateway) { + console.error(" The image appears to have reached the gateway before the stream failed."); + } + console.error(" If this repeats, restart Docker or the gateway and retry."); + return; + } + if (failure.kind === "sandbox_create_incomplete") { + console.error(" Hint: sandbox creation started but the create stream did not finish cleanly."); + console.error(" Recovery: nemoclaw onboard --resume"); + console.error( + " Check: openshell sandbox list # verify whether the sandbox became ready", + ); + return; + } + console.error(" Recovery: nemoclaw onboard --resume"); + console.error(" Or: nemoclaw onboard"); +} diff --git a/src/lib/chat-filter.test.ts b/src/lib/chat-filter.test.ts new file mode 100644 index 000000000..d70e5d054 --- /dev/null +++ b/src/lib/chat-filter.test.ts @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect } from "vitest"; +import { parseAllowedChatIds, isChatAllowed } from "../../dist/lib/chat-filter"; + +describe("lib/chat-filter", () => { + describe("parseAllowedChatIds", () => { + it("returns null for undefined input", () => { + expect(parseAllowedChatIds(undefined)).toBeNull(); + }); + + it("returns null for empty string", () => { + expect(parseAllowedChatIds("")).toBeNull(); + }); + + it("returns null for whitespace-only string", () => { + expect(parseAllowedChatIds(" , , ")).toBeNull(); + }); + + it("parses single chat ID", () => { + expect(parseAllowedChatIds("12345")).toEqual(["12345"]); + }); + + it("parses comma-separated chat IDs with whitespace", () => { + expect(parseAllowedChatIds("111, 222 ,333")).toEqual(["111", "222", "333"]); + }); + }); + + describe("isChatAllowed", () => { + it("allows all chats when allowed list is null", () => { + expect(isChatAllowed(null, "999")).toBe(true); + }); + + it("allows chat in the allowed list", () => { + expect(isChatAllowed(["111", "222"], "111")).toBe(true); + }); + + it("rejects chat not in the allowed list", () => { + expect(isChatAllowed(["111", "222"], "999")).toBe(false); + }); + }); +}); diff --git a/src/lib/chat-filter.ts b/src/lib/chat-filter.ts new file mode 100644 index 000000000..dfbcbd3b5 --- /dev/null +++ b/src/lib/chat-filter.ts @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Parse a comma-separated list of allowed chat IDs. + * Returns null if the input is empty or undefined (meaning: accept all). + */ +export function parseAllowedChatIds(raw: string | undefined): string[] | null { + if (!raw) return null; + const ids = raw + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + return ids.length > 0 ? ids : null; +} + +/** + * Check whether a chat ID is allowed by the parsed allowlist. + * + * When `allowedChats` is null every chat is accepted (open mode). + */ +export function isChatAllowed(allowedChats: string[] | null, chatId: string): boolean { + return !allowedChats || allowedChats.includes(chatId); +} diff --git a/src/lib/dashboard.test.ts b/src/lib/dashboard.test.ts new file mode 100644 index 000000000..8f812b3b6 --- /dev/null +++ b/src/lib/dashboard.test.ts @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +// Import from compiled dist/ so coverage is attributed correctly. +import { resolveDashboardForwardTarget, buildControlUiUrls } from "../../dist/lib/dashboard"; + +describe("resolveDashboardForwardTarget", () => { + it("returns port-only for localhost URL", () => { + expect(resolveDashboardForwardTarget("http://127.0.0.1:18789")).toBe("18789"); + }); + + it("returns port-only for localhost hostname", () => { + expect(resolveDashboardForwardTarget("http://localhost:18789")).toBe("18789"); + }); + + it("binds to 0.0.0.0 for non-loopback URL", () => { + expect(resolveDashboardForwardTarget("http://my-server.example.com:18789")).toBe( + "0.0.0.0:18789", + ); + }); + + it("returns port-only for empty input", () => { + expect(resolveDashboardForwardTarget("")).toBe("18789"); + }); + + it("returns port-only for default", () => { + expect(resolveDashboardForwardTarget()).toBe("18789"); + }); + + it("handles URL without scheme", () => { + expect(resolveDashboardForwardTarget("remote-host:18789")).toBe("0.0.0.0:18789"); + }); + + it("handles invalid URL containing localhost in catch path", () => { + // This triggers the catch branch since ://localhost is not a valid URL + expect(resolveDashboardForwardTarget("://localhost:bad")).toBe("18789"); + }); + + it("handles invalid URL containing 127.0.0.1 in catch path", () => { + expect(resolveDashboardForwardTarget("://127.0.0.1:bad")).toBe("18789"); + }); + + it("handles invalid URL containing ::1 in catch path", () => { + expect(resolveDashboardForwardTarget("://::1:bad")).toBe("18789"); + }); + + it("handles invalid URL with non-loopback in catch path", () => { + expect(resolveDashboardForwardTarget("://remote-host:bad")).toBe("0.0.0.0:18789"); + }); + + it("handles IPv6 loopback URL", () => { + expect(resolveDashboardForwardTarget("http://[::1]:18789")).toBe("18789"); + }); +}); + +describe("buildControlUiUrls", () => { + const originalEnv = process.env.CHAT_UI_URL; + + beforeEach(() => { + delete process.env.CHAT_UI_URL; + }); + + afterEach(() => { + if (originalEnv !== undefined) { + process.env.CHAT_UI_URL = originalEnv; + } else { + delete process.env.CHAT_UI_URL; + } + }); + + it("builds URL with token hash", () => { + const urls = buildControlUiUrls("my-token"); + expect(urls).toEqual(["http://127.0.0.1:18789/#token=my-token"]); + }); + + it("builds URL without token", () => { + const urls = buildControlUiUrls(null); + expect(urls).toEqual(["http://127.0.0.1:18789/"]); + }); + + it("includes CHAT_UI_URL when set", () => { + process.env.CHAT_UI_URL = "https://my-dashboard.example.com"; + const urls = buildControlUiUrls("tok"); + expect(urls).toHaveLength(2); + expect(urls[1]).toBe("https://my-dashboard.example.com/#token=tok"); + }); + + it("deduplicates when CHAT_UI_URL matches local", () => { + process.env.CHAT_UI_URL = "http://127.0.0.1:18789"; + const urls = buildControlUiUrls(null); + expect(urls).toHaveLength(1); + }); + + it("ignores non-http CHAT_UI_URL", () => { + process.env.CHAT_UI_URL = "ftp://example.com"; + const urls = buildControlUiUrls("tok"); + expect(urls).toHaveLength(1); + }); + + it("ignores empty CHAT_UI_URL", () => { + process.env.CHAT_UI_URL = " "; + const urls = buildControlUiUrls("tok"); + expect(urls).toHaveLength(1); + }); +}); diff --git a/src/lib/dashboard.ts b/src/lib/dashboard.ts new file mode 100644 index 000000000..45af5c59c --- /dev/null +++ b/src/lib/dashboard.ts @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Dashboard URL resolution and construction. + */ + +import { isLoopbackHostname } from "./url-utils"; + +const CONTROL_UI_PORT = 18789; +const CONTROL_UI_PATH = "/"; + +export function resolveDashboardForwardTarget( + chatUiUrl = `http://127.0.0.1:${CONTROL_UI_PORT}`, +): string { + const raw = String(chatUiUrl || "").trim(); + if (!raw) return String(CONTROL_UI_PORT); + try { + const parsed = new URL(/^[a-z]+:\/\//i.test(raw) ? raw : `http://${raw}`); + return isLoopbackHostname(parsed.hostname) + ? String(CONTROL_UI_PORT) + : `0.0.0.0:${CONTROL_UI_PORT}`; + } catch { + return /localhost|::1|127(?:\.\d{1,3}){3}/i.test(raw) + ? String(CONTROL_UI_PORT) + : `0.0.0.0:${CONTROL_UI_PORT}`; + } +} + +export function buildControlUiUrls(token: string | null = null): string[] { + const hash = token ? `#token=${token}` : ""; + const baseUrl = `http://127.0.0.1:${CONTROL_UI_PORT}`; + const urls = [`${baseUrl}${CONTROL_UI_PATH}${hash}`]; + const chatUi = (process.env.CHAT_UI_URL || "").trim().replace(/\/$/, ""); + if (chatUi && /^https?:\/\//i.test(chatUi) && chatUi !== baseUrl) { + urls.push(`${chatUi}${CONTROL_UI_PATH}${hash}`); + } + return [...new Set(urls)]; +} diff --git a/src/lib/debug.test.ts b/src/lib/debug.test.ts new file mode 100644 index 000000000..7aa76d195 --- /dev/null +++ b/src/lib/debug.test.ts @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect } from "vitest"; +// Import from compiled dist/ so coverage is attributed correctly. +import { redact } from "../../dist/lib/debug"; + +describe("redact", () => { + it("redacts NVIDIA_API_KEY=value patterns", () => { + const key = ["NVIDIA", "API", "KEY"].join("_"); + expect(redact(`${key}=some-value`)).toBe(`${key}=`); + }); + + it("redacts generic KEY/TOKEN/SECRET/PASSWORD env vars", () => { + expect(redact("API_KEY=secret123")).toBe("API_KEY="); + expect(redact("MY_TOKEN=tok_abc")).toBe("MY_TOKEN="); + expect(redact("DB_PASSWORD=hunter2")).toBe("DB_PASSWORD="); + expect(redact("MY_SECRET=s3cret")).toBe("MY_SECRET="); + expect(redact("CREDENTIAL=cred")).toBe("CREDENTIAL="); + }); + + it("redacts nvapi- prefixed keys", () => { + expect(redact("using key nvapi-AbCdEfGhIj1234")).toBe("using key "); + }); + + it("redacts classic GitHub personal access tokens (ghp_)", () => { + expect(redact("token: ghp_" + "a".repeat(36))).toBe("token: "); + }); + + it("redacts fine-grained GitHub personal access tokens (github_pat_)", () => { + expect(redact("token: github_pat_" + "A".repeat(40))).toBe("token: "); + }); + + it("redacts Bearer tokens", () => { + expect(redact("Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig")).toBe( + "Authorization: Bearer ", + ); + }); + + it("handles multiple patterns in one string", () => { + const input = "API_KEY=secret nvapi-abcdefghijk Bearer tok123"; + const result = redact(input); + expect(result).not.toContain("secret"); + expect(result).not.toContain("nvapi-abcdefghijk"); + expect(result).not.toContain("tok123"); + }); + + it("leaves clean text unchanged", () => { + const clean = "Hello world, no secrets here"; + expect(redact(clean)).toBe(clean); + }); +}); diff --git a/src/lib/debug.ts b/src/lib/debug.ts new file mode 100644 index 000000000..2ce3bd480 --- /dev/null +++ b/src/lib/debug.ts @@ -0,0 +1,487 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { execFileSync, spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; +import { platform, tmpdir } from "node:os"; +import { basename, dirname, join } from "node:path"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface DebugOptions { + /** Target sandbox name (auto-detected if omitted). */ + sandboxName?: string; + /** Only collect minimal diagnostics. */ + quick?: boolean; + /** Write a tarball to this path. */ + output?: string; +} + +// --------------------------------------------------------------------------- +// Colour helpers — respect NO_COLOR +// --------------------------------------------------------------------------- + +const useColor = !process.env.NO_COLOR && process.stdout.isTTY; +const GREEN = useColor ? "\x1b[0;32m" : ""; +const YELLOW = useColor ? "\x1b[1;33m" : ""; +const CYAN = useColor ? "\x1b[0;36m" : ""; +const NC = useColor ? "\x1b[0m" : ""; + +function info(msg: string): void { + console.log(`${GREEN}[debug]${NC} ${msg}`); +} + +function warn(msg: string): void { + console.log(`${YELLOW}[debug]${NC} ${msg}`); +} + +function section(title: string): void { + console.log(`\n${CYAN}═══ ${title} ═══${NC}\n`); +} + +// --------------------------------------------------------------------------- +// Secret redaction +// --------------------------------------------------------------------------- + +const REDACT_PATTERNS: [RegExp, string][] = [ + [/(NVIDIA_API_KEY|API_KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|_KEY)=\S+/gi, "$1="], + [/nvapi-[A-Za-z0-9_-]{10,}/g, ""], + [/(?:ghp_|github_pat_)[A-Za-z0-9_]{30,}/g, ""], + [/(Bearer )\S+/gi, "$1"], +]; + +export function redact(text: string): string { + let result = text; + for (const [pattern, replacement] of REDACT_PATTERNS) { + result = result.replace(pattern, replacement); + } + return result; +} + +// --------------------------------------------------------------------------- +// Command runner +// --------------------------------------------------------------------------- + +const isMacOS = platform() === "darwin"; +const TIMEOUT_MS = 30_000; + +function commandExists(cmd: string): boolean { + try { + // Use sh -c with the command as a separate argument to avoid shell injection. + // While cmd values are hardcoded internally, this is defensive. + execFileSync("sh", ["-c", `command -v "$1"`, "--", cmd], { + stdio: ["ignore", "ignore", "ignore"], + }); + return true; + } catch { + return false; + } +} + +function collect(collectDir: string, label: string, command: string, args: string[]): void { + const filename = label.replace(/[ /]/g, (c) => (c === " " ? "_" : "-")); + const outfile = join(collectDir, `${filename}.txt`); + + if (!commandExists(command)) { + const msg = ` (${command} not found, skipping)`; + console.log(msg); + writeFileSync(outfile, msg + "\n"); + return; + } + + const result = spawnSync(command, args, { + timeout: TIMEOUT_MS, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", + }); + + const raw = (result.stdout ?? "") + "\n" + (result.stderr ?? ""); + const redacted = redact(raw); + writeFileSync(outfile, redacted); + console.log(redacted.trimEnd()); + + if (result.status !== 0) { + console.log(" (command exited with non-zero status)"); + } +} + +/** Run a shell one-liner via `sh -c`. */ +function collectShell(collectDir: string, label: string, shellCmd: string): void { + const filename = label.replace(/[ /]/g, (c) => (c === " " ? "_" : "-")); + const outfile = join(collectDir, `${filename}.txt`); + + const result = spawnSync("sh", ["-c", shellCmd], { + timeout: TIMEOUT_MS, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", + }); + + const raw = (result.stdout ?? "") + "\n" + (result.stderr ?? ""); + const redacted = redact(raw); + writeFileSync(outfile, redacted); + console.log(redacted.trimEnd()); + + if (result.status !== 0) { + console.log(" (command exited with non-zero status)"); + } +} + +// --------------------------------------------------------------------------- +// Auto-detect sandbox name +// --------------------------------------------------------------------------- + +function detectSandboxName(): string { + if (!commandExists("openshell")) return "default"; + try { + const output = execFileSync("openshell", ["sandbox", "list"], { + encoding: "utf-8", + timeout: 10_000, + stdio: ["ignore", "pipe", "ignore"], + }); + const lines = output.split("\n").filter((l) => l.trim().length > 0); + for (const line of lines) { + const first = line.trim().split(/\s+/)[0]; + if (first && first.toLowerCase() !== "name") return first; + } + } catch { + /* ignore */ + } + return "default"; +} + +// --------------------------------------------------------------------------- +// Diagnostic sections +// --------------------------------------------------------------------------- + +function collectSystem(collectDir: string, quick: boolean): void { + section("System"); + collect(collectDir, "date", "date", []); + collect(collectDir, "uname", "uname", ["-a"]); + collect(collectDir, "uptime", "uptime", []); + + if (isMacOS) { + collectShell( + collectDir, + "memory", + 'echo "Physical: $(($(sysctl -n hw.memsize) / 1048576)) MB"; vm_stat', + ); + } else { + collect(collectDir, "free", "free", ["-m"]); + } + + if (!quick) { + collect(collectDir, "df", "df", ["-h"]); + } +} + +function collectProcesses(collectDir: string, quick: boolean): void { + section("Processes"); + if (isMacOS) { + collectShell( + collectDir, + "ps-cpu", + "ps -eo pid,ppid,comm,%mem,%cpu | sort -k5 -rn | head -30", + ); + } else { + collectShell( + collectDir, + "ps-cpu", + "ps -eo pid,ppid,cmd,%mem,%cpu --sort=-%cpu | head -30", + ); + } + + if (!quick) { + if (isMacOS) { + collectShell( + collectDir, + "ps-mem", + "ps -eo pid,ppid,comm,%mem,%cpu | sort -k4 -rn | head -30", + ); + collectShell(collectDir, "top", "top -l 1 | head -50"); + } else { + collectShell( + collectDir, + "ps-mem", + "ps -eo pid,ppid,cmd,%mem,%cpu --sort=-%mem | head -30", + ); + collectShell(collectDir, "top", "top -b -n 1 | head -50"); + } + } +} + +function collectGpu(collectDir: string, quick: boolean): void { + section("GPU"); + collect(collectDir, "nvidia-smi", "nvidia-smi", []); + + if (!quick) { + collect(collectDir, "nvidia-smi-dmon", "nvidia-smi", [ + "dmon", + "-s", + "pucvmet", + "-c", + "10", + ]); + collect(collectDir, "nvidia-smi-query", "nvidia-smi", [ + "--query-gpu=name,utilization.gpu,utilization.memory,memory.total,memory.used,temperature.gpu,power.draw", + "--format=csv", + ]); + } +} + +function collectDocker(collectDir: string, quick: boolean): void { + section("Docker"); + collect(collectDir, "docker-ps", "docker", ["ps", "-a"]); + collect(collectDir, "docker-stats", "docker", ["stats", "--no-stream"]); + + if (!quick) { + collect(collectDir, "docker-info", "docker", ["info"]); + collect(collectDir, "docker-df", "docker", ["system", "df"]); + } + + // NemoClaw-labelled containers + if (commandExists("docker")) { + try { + const output = execFileSync( + "docker", + ["ps", "-a", "--filter", "label=com.nvidia.nemoclaw", "--format", "{{.Names}}"], + { encoding: "utf-8", timeout: TIMEOUT_MS, stdio: ["ignore", "pipe", "ignore"] }, + ); + const containers = output.split("\n").filter((c) => c.trim().length > 0); + for (const cid of containers) { + collect(collectDir, `docker-logs-${cid}`, "docker", ["logs", "--tail", "200", cid]); + if (!quick) { + collect(collectDir, `docker-inspect-${cid}`, "docker", ["inspect", cid]); + } + } + } catch { + /* docker not available or timed out */ + } + } +} + +function collectOpenshell( + collectDir: string, + sandboxName: string, + quick: boolean, +): void { + section("OpenShell"); + collect(collectDir, "openshell-status", "openshell", ["status"]); + collect(collectDir, "openshell-sandbox-list", "openshell", ["sandbox", "list"]); + collect(collectDir, "openshell-sandbox-get", "openshell", ["sandbox", "get", sandboxName]); + collect(collectDir, "openshell-logs", "openshell", ["logs", sandboxName]); + + if (!quick) { + collect(collectDir, "openshell-gateway-info", "openshell", ["gateway", "info"]); + } +} + +function collectSandboxInternals( + collectDir: string, + sandboxName: string, + quick: boolean, +): void { + if (!commandExists("openshell")) return; + + // Check if sandbox exists + try { + const output = execFileSync("openshell", ["sandbox", "list"], { + encoding: "utf-8", + timeout: 10_000, + stdio: ["ignore", "pipe", "ignore"], + }); + const names = output + .split("\n") + .map((l) => l.trim().split(/\s+/)[0]) + .filter((n) => n && n.toLowerCase() !== "name"); + if (!names.includes(sandboxName)) return; + } catch { + return; + } + + section("Sandbox Internals"); + + // Generate temporary SSH config + const sshConfigPath = join(tmpdir(), `nemoclaw-ssh-${String(Date.now())}`); + try { + const sshResult = spawnSync("openshell", ["sandbox", "ssh-config", sandboxName], { + timeout: TIMEOUT_MS, + stdio: ["ignore", "pipe", "ignore"], + encoding: "utf-8", + }); + if (sshResult.status !== 0) { + warn(`Could not generate SSH config for sandbox '${sandboxName}', skipping internals`); + return; + } + writeFileSync(sshConfigPath, sshResult.stdout ?? ""); + + const sshHost = `openshell-${sandboxName}`; + const sshBase = [ + "-F", + sshConfigPath, + "-o", + "StrictHostKeyChecking=no", + "-o", + "ConnectTimeout=10", + sshHost, + ]; + + // Use collect() with array args — no shell interpolation of sandboxName + collect(collectDir, "sandbox-ps", "ssh", [...sshBase, "ps", "-ef"]); + collect(collectDir, "sandbox-free", "ssh", [...sshBase, "free", "-m"]); + if (!quick) { + collect(collectDir, "sandbox-top", "ssh", [ + ...sshBase, + "top", + "-b", + "-n", + "1", + ]); + collect(collectDir, "sandbox-gateway-log", "ssh", [ + ...sshBase, + "tail", + "-200", + "/tmp/gateway.log", + ]); + } + } finally { + if (existsSync(sshConfigPath)) { + unlinkSync(sshConfigPath); + } + } +} + +function collectNetwork(collectDir: string): void { + section("Network"); + if (isMacOS) { + collectShell(collectDir, "listening", "netstat -anp tcp | grep LISTEN"); + collect(collectDir, "ifconfig", "ifconfig", []); + collect(collectDir, "routes", "netstat", ["-rn"]); + collect(collectDir, "dns-config", "scutil", ["--dns"]); + } else { + collect(collectDir, "ss", "ss", ["-ltnp"]); + collect(collectDir, "ip-addr", "ip", ["addr"]); + collect(collectDir, "ip-route", "ip", ["route"]); + collectShell(collectDir, "resolv-conf", "cat /etc/resolv.conf"); + } + collect(collectDir, "nslookup", "nslookup", ["integrate.api.nvidia.com"]); + collectShell( + collectDir, + "curl-models", + 'code=$(curl -s -o /dev/null -w "%{http_code}" https://integrate.api.nvidia.com/v1/models); echo "HTTP $code"; if [ "$code" -ge 200 ] && [ "$code" -lt 500 ]; then echo "NIM API reachable"; else echo "NIM API unreachable"; exit 1; fi', + ); + collectShell(collectDir, "lsof-net", "lsof -i -P -n 2>/dev/null | head -50"); + collect(collectDir, "lsof-18789", "lsof", ["-i", ":18789"]); +} + +function collectOnboardSession(collectDir: string, repoDir: string): void { + section("Onboard Session"); + const helperPath = join(repoDir, "bin", "lib", "onboard-session.js"); + if (!existsSync(helperPath) || !commandExists("node")) { + console.log(" (onboard session helper not available, skipping)"); + return; + } + + const script = [ + "const helper = require(process.argv[1]);", + "const summary = helper.summarizeForDebug();", + "if (!summary) { process.stdout.write('No onboard session state found.\\n'); process.exit(0); }", + "process.stdout.write(JSON.stringify(summary, null, 2) + '\\n');", + ].join(" "); + + collect(collectDir, "onboard-session-summary", "node", ["-e", script, helperPath]); +} + +function collectKernel(collectDir: string): void { + section("Kernel / IO"); + if (isMacOS) { + collect(collectDir, "vmstat", "vm_stat", []); + collect(collectDir, "iostat", "iostat", ["-c", "5", "-w", "1"]); + } else { + collect(collectDir, "vmstat", "vmstat", ["1", "5"]); + collect(collectDir, "iostat", "iostat", ["-xz", "1", "5"]); + } +} + +function collectKernelMessages(collectDir: string): void { + section("Kernel Messages"); + if (isMacOS) { + collectShell( + collectDir, + "system-log", + 'log show --last 5m --predicate "eventType == logEvent" --style compact 2>/dev/null | tail -100', + ); + } else { + collectShell(collectDir, "dmesg", "dmesg | tail -100"); + } +} + +// --------------------------------------------------------------------------- +// Tarball +// --------------------------------------------------------------------------- + +function createTarball(collectDir: string, output: string): void { + spawnSync("tar", ["czf", output, "-C", dirname(collectDir), basename(collectDir)], { + stdio: "inherit", + timeout: 60_000, + }); + info(`Tarball written to ${output}`); + warn( + "Known secrets are auto-redacted, but please review for any remaining sensitive data before sharing.", + ); + info("Attach this file to your GitHub issue."); +} + +// --------------------------------------------------------------------------- +// Main entry point +// --------------------------------------------------------------------------- + +export function runDebug(opts: DebugOptions = {}): void { + const quick = opts.quick ?? false; + const output = opts.output ?? ""; + // Compiled location: dist/lib/debug.js → repo root is 2 levels up + const repoDir = join(__dirname, "..", ".."); + + // Resolve sandbox name + let sandboxName = + opts.sandboxName ?? process.env.NEMOCLAW_SANDBOX ?? process.env.SANDBOX_NAME ?? ""; + if (!sandboxName) { + sandboxName = detectSandboxName(); + } + + // Create temp collection directory + const collectDir = mkdtempSync(join(tmpdir(), "nemoclaw-debug-")); + + try { + info(`Collecting diagnostics for sandbox '${sandboxName}'...`); + info(`Quick mode: ${String(quick)}`); + if (output) info(`Tarball output: ${output}`); + console.log(""); + + collectSystem(collectDir, quick); + collectProcesses(collectDir, quick); + collectGpu(collectDir, quick); + collectDocker(collectDir, quick); + collectOpenshell(collectDir, sandboxName, quick); + collectOnboardSession(collectDir, repoDir); + collectSandboxInternals(collectDir, sandboxName, quick); + + if (!quick) { + collectNetwork(collectDir); + collectKernel(collectDir); + } + + collectKernelMessages(collectDir); + + if (output) { + createTarball(collectDir, output); + } + + console.log(""); + info("Done. If filing a bug, run with --output and attach the tarball to your issue:"); + info(" nemoclaw debug --output /tmp/nemoclaw-debug.tar.gz"); + } finally { + rmSync(collectDir, { recursive: true, force: true }); + } +} diff --git a/src/lib/gateway-state.ts b/src/lib/gateway-state.ts new file mode 100644 index 000000000..1b78a02d2 --- /dev/null +++ b/src/lib/gateway-state.ts @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Pure classifiers for OpenShell gateway and sandbox state. + * + * Every function here takes string output from openshell CLI commands and + * returns a typed result — no I/O, no side effects. + */ + +const GATEWAY_NAME = "nemoclaw"; + +// eslint-disable-next-line no-control-regex +const ANSI_RE = /\x1b\[[0-9;]*m/g; + +function stripAnsi(value: string): string { + return value.replace(ANSI_RE, ""); +} + +export type GatewayReuseState = + | "healthy" + | "active-unnamed" + | "foreign-active" + | "stale" + | "missing"; + +export type SandboxState = "ready" | "not_ready" | "missing"; + +/** + * Check if a sandbox is in Ready state from `openshell sandbox list` output. + * Strips ANSI codes and exact-matches the sandbox name in the first column. + */ +export function isSandboxReady(output: string, sandboxName: string): boolean { + const clean = stripAnsi(output); + return clean.split("\n").some((l) => { + const cols = l.trim().split(/\s+/); + return cols[0] === sandboxName && cols.includes("Ready") && !cols.includes("NotReady"); + }); +} + +/** + * Determine whether stale NemoClaw gateway output indicates a previous + * session that should be cleaned up before the port preflight check. + */ +export function hasStaleGateway(gwInfoOutput: string): boolean { + const clean = typeof gwInfoOutput === "string" ? stripAnsi(gwInfoOutput) : ""; + return ( + clean.length > 0 && + clean.includes(`Gateway: ${GATEWAY_NAME}`) && + !clean.includes("No gateway metadata found") + ); +} + +export function getReportedGatewayName(output = ""): string | null { + if (typeof output !== "string") return null; + const clean = stripAnsi(output); + const match = clean.match(/^\s*Gateway:\s+([^\s]+)/m); + return match ? match[1] : null; +} + +export function isGatewayConnected(statusOutput = ""): boolean { + return typeof statusOutput === "string" && statusOutput.includes("Connected"); +} + +export function hasActiveGatewayInfo(activeGatewayInfoOutput = ""): boolean { + return ( + typeof activeGatewayInfoOutput === "string" && + activeGatewayInfoOutput.includes("Gateway endpoint:") && + !activeGatewayInfoOutput.includes("No gateway metadata found") + ); +} + +export function isSelectedGateway(statusOutput = "", gatewayName = GATEWAY_NAME): boolean { + return getReportedGatewayName(statusOutput) === gatewayName; +} + +export function isGatewayHealthy( + statusOutput = "", + gwInfoOutput = "", + activeGatewayInfoOutput = "", +): boolean { + const namedGatewayKnown = hasStaleGateway(gwInfoOutput); + if (!namedGatewayKnown || !isGatewayConnected(statusOutput)) return false; + + const activeGatewayName = + getReportedGatewayName(statusOutput) || getReportedGatewayName(activeGatewayInfoOutput); + return activeGatewayName === GATEWAY_NAME; +} + +export function getGatewayReuseState( + statusOutput = "", + gwInfoOutput = "", + activeGatewayInfoOutput = "", +): GatewayReuseState { + if (isGatewayHealthy(statusOutput, gwInfoOutput, activeGatewayInfoOutput)) { + return "healthy"; + } + const connected = isGatewayConnected(statusOutput); + const activeGatewayName = + getReportedGatewayName(statusOutput) || getReportedGatewayName(activeGatewayInfoOutput); + if (connected && activeGatewayName === GATEWAY_NAME) { + return "active-unnamed"; + } + if (connected && activeGatewayName && activeGatewayName !== GATEWAY_NAME) { + return "foreign-active"; + } + if (hasStaleGateway(gwInfoOutput)) { + return "stale"; + } + if (hasActiveGatewayInfo(activeGatewayInfoOutput)) { + return "active-unnamed"; + } + return "missing"; +} + +export function getSandboxStateFromOutputs( + sandboxName: string, + getOutput = "", + listOutput = "", +): SandboxState { + if (!sandboxName) return "missing"; + if (!getOutput) return "missing"; + return isSandboxReady(listOutput, sandboxName) ? "ready" : "not_ready"; +} diff --git a/src/lib/inference-config.test.ts b/src/lib/inference-config.test.ts new file mode 100644 index 000000000..81dc9d044 --- /dev/null +++ b/src/lib/inference-config.test.ts @@ -0,0 +1,213 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect } from "vitest"; + +// Import from compiled dist/ for correct coverage attribution. +import { + CLOUD_MODEL_OPTIONS, + DEFAULT_OLLAMA_MODEL, + DEFAULT_ROUTE_CREDENTIAL_ENV, + DEFAULT_ROUTE_PROFILE, + INFERENCE_ROUTE_URL, + MANAGED_PROVIDER_ID, + getOpenClawPrimaryModel, + getProviderSelectionConfig, + parseGatewayInference, +} from "../../dist/lib/inference-config"; + +describe("inference selection config", () => { + it("exposes the curated cloud model picker options", () => { + expect(CLOUD_MODEL_OPTIONS.map((option: { id: string }) => option.id)).toEqual([ + "nvidia/nemotron-3-super-120b-a12b", + "moonshotai/kimi-k2.5", + "z-ai/glm5", + "minimaxai/minimax-m2.5", + "openai/gpt-oss-120b", + ]); + }); + + it("maps ollama-local to the sandbox inference route and default model", () => { + expect(getProviderSelectionConfig("ollama-local")).toEqual({ + endpointType: "custom", + endpointUrl: INFERENCE_ROUTE_URL, + ncpPartner: null, + model: DEFAULT_OLLAMA_MODEL, + profile: DEFAULT_ROUTE_PROFILE, + credentialEnv: DEFAULT_ROUTE_CREDENTIAL_ENV, + provider: "ollama-local", + providerLabel: "Local Ollama", + }); + }); + + it("maps nvidia-nim to the sandbox inference route", () => { + expect(getProviderSelectionConfig("nvidia-nim", "nvidia/nemotron-3-super-120b-a12b")).toEqual({ + endpointType: "custom", + endpointUrl: INFERENCE_ROUTE_URL, + ncpPartner: null, + model: "nvidia/nemotron-3-super-120b-a12b", + profile: DEFAULT_ROUTE_PROFILE, + credentialEnv: DEFAULT_ROUTE_CREDENTIAL_ENV, + provider: "nvidia-nim", + providerLabel: "NVIDIA Endpoints", + }); + }); + + it("maps compatible-anthropic-endpoint to the sandbox inference route", () => { + expect( + getProviderSelectionConfig("compatible-anthropic-endpoint", "claude-sonnet-proxy"), + ).toEqual({ + endpointType: "custom", + endpointUrl: INFERENCE_ROUTE_URL, + ncpPartner: null, + model: "claude-sonnet-proxy", + profile: DEFAULT_ROUTE_PROFILE, + credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY", + provider: "compatible-anthropic-endpoint", + providerLabel: "Other Anthropic-compatible endpoint", + }); + }); + + it("maps the remaining hosted providers to the sandbox inference route", () => { + // Full-object assertion for one hosted provider to catch structural regressions + expect(getProviderSelectionConfig("openai-api", "gpt-5.4-mini")).toEqual({ + endpointType: "custom", + endpointUrl: INFERENCE_ROUTE_URL, + ncpPartner: null, + model: "gpt-5.4-mini", + profile: DEFAULT_ROUTE_PROFILE, + credentialEnv: "OPENAI_API_KEY", + provider: "openai-api", + providerLabel: "OpenAI", + }); + expect(getProviderSelectionConfig("anthropic-prod", "claude-sonnet-4-6")).toEqual( + expect.objectContaining({ model: "claude-sonnet-4-6", providerLabel: "Anthropic" }), + ); + expect(getProviderSelectionConfig("gemini-api", "gemini-2.5-pro")).toEqual( + expect.objectContaining({ model: "gemini-2.5-pro", providerLabel: "Google Gemini" }), + ); + expect(getProviderSelectionConfig("compatible-endpoint", "openrouter/auto")).toEqual( + expect.objectContaining({ + model: "openrouter/auto", + providerLabel: "Other OpenAI-compatible endpoint", + }), + ); + // Full-object assertion for one local provider + expect(getProviderSelectionConfig("vllm-local", "meta-llama")).toEqual({ + endpointType: "custom", + endpointUrl: INFERENCE_ROUTE_URL, + ncpPartner: null, + model: "meta-llama", + profile: DEFAULT_ROUTE_PROFILE, + credentialEnv: DEFAULT_ROUTE_CREDENTIAL_ENV, + provider: "vllm-local", + providerLabel: "Local vLLM", + }); + }); + + it("returns null for unknown providers", () => { + expect(getProviderSelectionConfig("bogus-provider")).toBe(null); + }); + + it("does not grow beyond the approved provider set", () => { + const APPROVED_PROVIDERS = [ + "nvidia-prod", + "nvidia-nim", + "openai-api", + "anthropic-prod", + "compatible-anthropic-endpoint", + "gemini-api", + "compatible-endpoint", + "vllm-local", + "ollama-local", + ]; + for (const key of APPROVED_PROVIDERS) { + expect(getProviderSelectionConfig(key)).not.toBe(null); + } + const CANDIDATES = [ + "bedrock", + "vertex", + "azure", + "azure-openai", + "deepseek", + "mistral", + "cohere", + "fireworks", + "together", + "groq", + "lambda", + "replicate", + "perplexity", + "sambanova", + ]; + for (const key of CANDIDATES) { + expect(getProviderSelectionConfig(key)).toBe(null); + } + }); + + it("falls back to provider defaults when model is omitted", () => { + expect(getProviderSelectionConfig("openai-api")?.model).toBe("gpt-5.4"); + expect(getProviderSelectionConfig("anthropic-prod")?.model).toBe("claude-sonnet-4-6"); + expect(getProviderSelectionConfig("gemini-api")?.model).toBe("gemini-2.5-flash"); + expect(getProviderSelectionConfig("compatible-endpoint")?.model).toBe("custom-model"); + expect(getProviderSelectionConfig("compatible-anthropic-endpoint")?.model).toBe( + "custom-anthropic-model", + ); + expect(getProviderSelectionConfig("vllm-local")?.model).toBe("vllm-local"); + }); + + it("builds a qualified OpenClaw primary model for ollama-local", () => { + expect(getOpenClawPrimaryModel("ollama-local", "nemotron-3-nano:30b")).toBe( + `${MANAGED_PROVIDER_ID}/nemotron-3-nano:30b`, + ); + }); + + it("builds a default OpenClaw primary model for non-ollama providers", () => { + expect(getOpenClawPrimaryModel("nvidia-prod")).toBe( + `${MANAGED_PROVIDER_ID}/nvidia/nemotron-3-super-120b-a12b`, + ); + expect(getOpenClawPrimaryModel("ollama-local")).toBe( + `${MANAGED_PROVIDER_ID}/${DEFAULT_OLLAMA_MODEL}`, + ); + }); +}); + +describe("parseGatewayInference", () => { + it("parses provider and model from openshell inference get output", () => { + const output = [ + "Gateway inference:", + "", + " Provider: nvidia-nim", + " Model: nvidia/nemotron-3-super-120b-a12b", + " Version: 2", + ].join("\n"); + expect(parseGatewayInference(output)).toEqual({ + provider: "nvidia-nim", + model: "nvidia/nemotron-3-super-120b-a12b", + }); + }); + + it("returns null for empty output", () => { + expect(parseGatewayInference("")).toBeNull(); + expect(parseGatewayInference(null)).toBeNull(); + expect(parseGatewayInference(undefined)).toBeNull(); + }); + + it("returns null when inference is not configured", () => { + expect(parseGatewayInference("Gateway inference:\n\n Not configured")).toBeNull(); + }); + + it("handles output with only provider (no model line)", () => { + expect(parseGatewayInference("Gateway inference:\n\n Provider: nvidia-nim")).toEqual({ + provider: "nvidia-nim", + model: null, + }); + }); + + it("handles output with only model (no provider line)", () => { + expect(parseGatewayInference("Gateway inference:\n\n Model: some/model")).toEqual({ + provider: null, + model: "some/model", + }); + }); +}); diff --git a/src/lib/inference-config.ts b/src/lib/inference-config.ts new file mode 100644 index 000000000..f17fb860f --- /dev/null +++ b/src/lib/inference-config.ts @@ -0,0 +1,149 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Inference provider selection config, model resolution, and gateway + * inference output parsing. All functions are pure. + */ + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { DEFAULT_OLLAMA_MODEL } = require("../../bin/lib/local-inference"); + +export const INFERENCE_ROUTE_URL = "https://inference.local/v1"; +export const DEFAULT_CLOUD_MODEL = "nvidia/nemotron-3-super-120b-a12b"; +export const CLOUD_MODEL_OPTIONS = [ + { id: "nvidia/nemotron-3-super-120b-a12b", label: "Nemotron 3 Super 120B" }, + { id: "moonshotai/kimi-k2.5", label: "Kimi K2.5" }, + { id: "z-ai/glm5", label: "GLM-5" }, + { id: "minimaxai/minimax-m2.5", label: "MiniMax M2.5" }, + { id: "openai/gpt-oss-120b", label: "GPT-OSS 120B" }, +]; +export const DEFAULT_ROUTE_PROFILE = "inference-local"; +export const DEFAULT_ROUTE_CREDENTIAL_ENV = "OPENAI_API_KEY"; +export const MANAGED_PROVIDER_ID = "inference"; +export { DEFAULT_OLLAMA_MODEL }; + +export interface ProviderSelectionConfig { + endpointType: string; + endpointUrl: string; + ncpPartner: string | null; + model: string; + profile: string; + credentialEnv: string; + provider: string; + providerLabel: string; +} + +export interface GatewayInference { + provider: string | null; + model: string | null; +} + +export function getProviderSelectionConfig( + provider: string, + model?: string, +): ProviderSelectionConfig | null { + const base = { + endpointType: "custom" as const, + endpointUrl: INFERENCE_ROUTE_URL, + ncpPartner: null, + profile: DEFAULT_ROUTE_PROFILE, + provider, + }; + + switch (provider) { + case "nvidia-prod": + case "nvidia-nim": + return { + ...base, + model: model || DEFAULT_CLOUD_MODEL, + credentialEnv: DEFAULT_ROUTE_CREDENTIAL_ENV, + providerLabel: "NVIDIA Endpoints", + }; + case "openai-api": + return { + ...base, + model: model || "gpt-5.4", + credentialEnv: "OPENAI_API_KEY", + providerLabel: "OpenAI", + }; + case "anthropic-prod": + return { + ...base, + model: model || "claude-sonnet-4-6", + credentialEnv: "ANTHROPIC_API_KEY", + providerLabel: "Anthropic", + }; + case "compatible-anthropic-endpoint": + return { + ...base, + model: model || "custom-anthropic-model", + credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY", + providerLabel: "Other Anthropic-compatible endpoint", + }; + case "gemini-api": + return { + ...base, + model: model || "gemini-2.5-flash", + credentialEnv: "GEMINI_API_KEY", + providerLabel: "Google Gemini", + }; + case "compatible-endpoint": + return { + ...base, + model: model || "custom-model", + credentialEnv: "COMPATIBLE_API_KEY", + providerLabel: "Other OpenAI-compatible endpoint", + }; + case "vllm-local": + return { + ...base, + model: model || "vllm-local", + credentialEnv: DEFAULT_ROUTE_CREDENTIAL_ENV, + providerLabel: "Local vLLM", + }; + case "ollama-local": + return { + ...base, + model: model || DEFAULT_OLLAMA_MODEL, + credentialEnv: DEFAULT_ROUTE_CREDENTIAL_ENV, + providerLabel: "Local Ollama", + }; + default: + return null; + } +} + +export function getOpenClawPrimaryModel(provider: string, model?: string): string { + const resolvedModel = + model || (provider === "ollama-local" ? DEFAULT_OLLAMA_MODEL : DEFAULT_CLOUD_MODEL); + return `${MANAGED_PROVIDER_ID}/${resolvedModel}`; +} + +export function parseGatewayInference(output: string | null | undefined): GatewayInference | null { + if (!output) return null; + // eslint-disable-next-line no-control-regex + const stripped = output.replace(/\u001b\[[0-9;]*m/g, ""); + const lines = stripped.split("\n"); + let inGateway = false; + let provider: string | null = null; + let model: string | null = null; + for (const line of lines) { + if (/^Gateway inference:\s*$/i.test(line)) { + inGateway = true; + continue; + } + if (inGateway && /^\S.*:$/.test(line)) { + break; + } + if (inGateway) { + const trimmed = line.trim(); + const p = trimmed.match(/^Provider:\s*(.+)/); + const m = trimmed.match(/^Model:\s*(.+)/); + if (p) provider = p[1].trim(); + if (m) model = m[1].trim(); + } + } + if (!provider && !model) return null; + return { provider, model }; +} diff --git a/src/lib/local-inference.test.ts b/src/lib/local-inference.test.ts new file mode 100644 index 000000000..34040c814 --- /dev/null +++ b/src/lib/local-inference.test.ts @@ -0,0 +1,254 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect } from "vitest"; + +// Import from compiled dist/ for correct coverage attribution. +import { + CONTAINER_REACHABILITY_IMAGE, + DEFAULT_OLLAMA_MODEL, + LARGE_OLLAMA_MIN_MEMORY_MB, + getDefaultOllamaModel, + getBootstrapOllamaModelOptions, + getLocalProviderBaseUrl, + getLocalProviderContainerReachabilityCheck, + getLocalProviderHealthCheck, + getLocalProviderValidationBaseUrl, + getOllamaModelOptions, + getOllamaProbeCommand, + getOllamaWarmupCommand, + parseOllamaList, + parseOllamaTags, + validateOllamaModel, + validateLocalProvider, +} from "../../dist/lib/local-inference"; + +describe("local inference helpers", () => { + it("returns the expected base URL for vllm-local", () => { + expect(getLocalProviderBaseUrl("vllm-local")).toBe("http://host.openshell.internal:8000/v1"); + }); + + it("returns the expected base URL for ollama-local", () => { + expect(getLocalProviderBaseUrl("ollama-local")).toBe( + "http://host.openshell.internal:11434/v1", + ); + }); + + it("returns null for unknown local provider URLs", () => { + expect(getLocalProviderBaseUrl("unknown-provider")).toBeNull(); + expect(getLocalProviderValidationBaseUrl("unknown-provider")).toBeNull(); + expect(getLocalProviderHealthCheck("unknown-provider")).toBeNull(); + expect(getLocalProviderContainerReachabilityCheck("unknown-provider")).toBeNull(); + }); + + it("returns the expected validation URL for vllm-local", () => { + expect(getLocalProviderValidationBaseUrl("vllm-local")).toBe("http://localhost:8000/v1"); + }); + + it("returns the expected health check command for ollama-local", () => { + expect(getLocalProviderHealthCheck("ollama-local")).toBe( + "curl -sf http://localhost:11434/api/tags 2>/dev/null", + ); + }); + + it("returns the expected validation and health check commands for vllm-local", () => { + expect(getLocalProviderValidationBaseUrl("ollama-local")).toBe("http://localhost:11434/v1"); + expect(getLocalProviderHealthCheck("vllm-local")).toBe( + "curl -sf http://localhost:8000/v1/models 2>/dev/null", + ); + expect(getLocalProviderContainerReachabilityCheck("vllm-local")).toBe( + `docker run --rm --add-host host.openshell.internal:host-gateway ${CONTAINER_REACHABILITY_IMAGE} -sf http://host.openshell.internal:8000/v1/models 2>/dev/null`, + ); + }); + + it("returns the expected container reachability command for ollama-local", () => { + expect(getLocalProviderContainerReachabilityCheck("ollama-local")).toBe( + `docker run --rm --add-host host.openshell.internal:host-gateway ${CONTAINER_REACHABILITY_IMAGE} -sf http://host.openshell.internal:11434/api/tags 2>/dev/null`, + ); + }); + + it("validates a reachable local provider", () => { + let callCount = 0; + const result = validateLocalProvider("ollama-local", () => { + callCount += 1; + return '{"models":[]}'; + }); + expect(result).toEqual({ ok: true }); + expect(callCount).toBe(2); + }); + + it("returns a clear error when ollama-local is unavailable", () => { + const result = validateLocalProvider("ollama-local", () => ""); + expect(result.ok).toBe(false); + expect(result.message).toMatch(/http:\/\/localhost:11434/); + }); + + it("returns a clear error when ollama-local is not reachable from containers", () => { + let callCount = 0; + const result = validateLocalProvider("ollama-local", () => { + callCount += 1; + return callCount === 1 ? '{"models":[]}' : ""; + }); + expect(result.ok).toBe(false); + expect(result.message).toMatch(/host\.openshell\.internal:11434/); + expect(result.message).toMatch(/0\.0\.0\.0:11434/); + }); + + it("returns a clear error when vllm-local is unavailable", () => { + const result = validateLocalProvider("vllm-local", () => ""); + expect(result.ok).toBe(false); + expect(result.message).toMatch(/http:\/\/localhost:8000/); + }); + + it("returns a clear error when vllm-local is not reachable from containers", () => { + let callCount = 0; + const result = validateLocalProvider("vllm-local", () => { + callCount += 1; + return callCount === 1 ? '{"data":[]}' : ""; + }); + expect(result.ok).toBe(false); + expect(result.message).toMatch(/host\.openshell\.internal:8000/); + }); + + it("treats unknown local providers as already valid", () => { + expect(validateLocalProvider("custom-provider", () => "")).toEqual({ ok: true }); + }); + + it("skips health check entirely for unknown providers", () => { + let callCount = 0; + const result = validateLocalProvider("custom-provider", () => { + callCount += 1; + return callCount <= 1 ? "ok" : ""; + }); + // custom-provider has no health check command, so it returns ok immediately + expect(result).toEqual({ ok: true }); + }); + + it("parses model names from ollama list output", () => { + expect( + parseOllamaList( + [ + "NAME ID SIZE MODIFIED", + "nemotron-3-nano:30b abc123 24 GB 2 hours ago", + "qwen3:32b def456 20 GB 1 day ago", + ].join("\n"), + ), + ).toEqual(["nemotron-3-nano:30b", "qwen3:32b"]); + }); + + it("ignores headers and blank lines in ollama list output", () => { + expect(parseOllamaList("NAME ID SIZE MODIFIED\n\n")).toEqual([]); + }); + + it("returns parsed ollama model options when available", () => { + expect( + getOllamaModelOptions( + () => "nemotron-3-nano:30b abc 24 GB now\nqwen3:32b def 20 GB now", + ), + ).toEqual(["nemotron-3-nano:30b", "qwen3:32b"]); + }); + + it("parses installed models from Ollama /api/tags output", () => { + expect( + parseOllamaTags( + JSON.stringify({ + models: [{ name: "nemotron-3-nano:30b" }, { name: "qwen2.5:7b" }], + }), + ), + ).toEqual(["nemotron-3-nano:30b", "qwen2.5:7b"]); + }); + + it("returns no tags for malformed Ollama API output", () => { + expect(parseOllamaTags("{not-json")).toEqual([]); + expect(parseOllamaTags(JSON.stringify({ models: null }))).toEqual([]); + expect(parseOllamaTags(JSON.stringify({ models: [{}, { name: "qwen2.5:7b" }] }))).toEqual([ + "qwen2.5:7b", + ]); + }); + + it("prefers Ollama /api/tags over parsing the CLI list output", () => { + let call = 0; + expect( + getOllamaModelOptions(() => { + call += 1; + if (call === 1) { + return JSON.stringify({ models: [{ name: "qwen2.5:7b" }] }); + } + return ""; + }), + ).toEqual(["qwen2.5:7b"]); + }); + + it("returns no installed ollama models when list output is empty", () => { + expect(getOllamaModelOptions(() => "")).toEqual([]); + }); + + it("prefers the default ollama model when present", () => { + expect( + getDefaultOllamaModel( + () => "qwen3:32b abc 20 GB now\nnemotron-3-nano:30b def 24 GB now", + ), + ).toBe(DEFAULT_OLLAMA_MODEL); + }); + + it("falls back to the first listed ollama model when the default is absent", () => { + expect( + getDefaultOllamaModel(() => "qwen3:32b abc 20 GB now\ngemma3:4b def 3 GB now"), + ).toBe("qwen3:32b"); + }); + + it("falls back to bootstrap model options when no Ollama models are installed", () => { + expect(getBootstrapOllamaModelOptions(null)).toEqual(["qwen2.5:7b"]); + expect( + getBootstrapOllamaModelOptions({ totalMemoryMB: LARGE_OLLAMA_MIN_MEMORY_MB - 1 }), + ).toEqual(["qwen2.5:7b"]); + expect( + getBootstrapOllamaModelOptions({ totalMemoryMB: LARGE_OLLAMA_MIN_MEMORY_MB }), + ).toEqual(["qwen2.5:7b", DEFAULT_OLLAMA_MODEL]); + expect(getDefaultOllamaModel(() => "", { totalMemoryMB: 16384 })).toBe("qwen2.5:7b"); + }); + + it("builds a background warmup command for ollama models", () => { + const command = getOllamaWarmupCommand("nemotron-3-nano:30b"); + expect(command).toMatch(/^nohup curl -s http:\/\/localhost:11434\/api\/generate /); + expect(command).toMatch(/"model":"nemotron-3-nano:30b"/); + expect(command).toMatch(/"keep_alive":"15m"/); + }); + + it("supports custom probe and warmup tuning", () => { + expect(getOllamaWarmupCommand("qwen2.5:7b", "30m")).toMatch(/"keep_alive":"30m"/); + expect(getOllamaProbeCommand("qwen2.5:7b", 30, "5m")).toMatch(/--max-time 30/); + expect(getOllamaProbeCommand("qwen2.5:7b", 30, "5m")).toMatch(/"keep_alive":"5m"/); + }); + + it("builds a foreground probe command for ollama models", () => { + const command = getOllamaProbeCommand("nemotron-3-nano:30b"); + expect(command).toMatch(/^curl -sS --max-time 120 http:\/\/localhost:11434\/api\/generate /); + expect(command).toMatch(/"model":"nemotron-3-nano:30b"/); + }); + + it("fails ollama model validation when the probe times out or returns nothing", () => { + const result = validateOllamaModel("nemotron-3-nano:30b", () => ""); + expect(result.ok).toBe(false); + expect(result.message).toMatch(/did not answer the local probe in time/); + }); + + it("fails ollama model validation when Ollama returns an error payload", () => { + const result = validateOllamaModel("gabegoodhart/minimax-m2.1:latest", () => + JSON.stringify({ error: "model requires more system memory" }), + ); + expect(result.ok).toBe(false); + expect(result.message).toMatch(/requires more system memory/); + }); + + it("passes ollama model validation when the probe returns a normal payload", () => { + const result = validateOllamaModel("nemotron-3-nano:30b", () => + JSON.stringify({ model: "nemotron-3-nano:30b", response: "hello", done: true }), + ); + expect(result).toEqual({ ok: true }); + }); + + it("treats non-JSON probe output as success once the model responds", () => { + expect(validateOllamaModel("nemotron-3-nano:30b", () => "ok")).toEqual({ ok: true }); + }); +}); diff --git a/src/lib/local-inference.ts b/src/lib/local-inference.ts new file mode 100644 index 000000000..9390bb70e --- /dev/null +++ b/src/lib/local-inference.ts @@ -0,0 +1,237 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Local inference provider helpers — URL mappers, Ollama parsers, + * health checks, and command generators for vLLM and Ollama. + */ + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { shellQuote } = require("../../bin/lib/runner"); + +export const HOST_GATEWAY_URL = "http://host.openshell.internal"; +export const CONTAINER_REACHABILITY_IMAGE = "curlimages/curl:8.10.1"; +export const DEFAULT_OLLAMA_MODEL = "nemotron-3-nano:30b"; +export const SMALL_OLLAMA_MODEL = "qwen2.5:7b"; +export const LARGE_OLLAMA_MIN_MEMORY_MB = 32768; + +export type RunCaptureFn = (cmd: string, opts?: { ignoreError?: boolean }) => string; + +export interface GpuInfo { + totalMemoryMB: number; +} + +export interface ValidationResult { + ok: boolean; + message?: string; +} + +export function getLocalProviderBaseUrl(provider: string): string | null { + switch (provider) { + case "vllm-local": + return `${HOST_GATEWAY_URL}:8000/v1`; + case "ollama-local": + return `${HOST_GATEWAY_URL}:11434/v1`; + default: + return null; + } +} + +export function getLocalProviderValidationBaseUrl(provider: string): string | null { + switch (provider) { + case "vllm-local": + return "http://localhost:8000/v1"; + case "ollama-local": + return "http://localhost:11434/v1"; + default: + return null; + } +} + +export function getLocalProviderHealthCheck(provider: string): string | null { + switch (provider) { + case "vllm-local": + return "curl -sf http://localhost:8000/v1/models 2>/dev/null"; + case "ollama-local": + return "curl -sf http://localhost:11434/api/tags 2>/dev/null"; + default: + return null; + } +} + +export function getLocalProviderContainerReachabilityCheck(provider: string): string | null { + switch (provider) { + case "vllm-local": + return `docker run --rm --add-host host.openshell.internal:host-gateway ${CONTAINER_REACHABILITY_IMAGE} -sf http://host.openshell.internal:8000/v1/models 2>/dev/null`; + case "ollama-local": + return `docker run --rm --add-host host.openshell.internal:host-gateway ${CONTAINER_REACHABILITY_IMAGE} -sf http://host.openshell.internal:11434/api/tags 2>/dev/null`; + default: + return null; + } +} + +export function validateLocalProvider( + provider: string, + runCapture: RunCaptureFn, +): ValidationResult { + const command = getLocalProviderHealthCheck(provider); + if (!command) { + return { ok: true }; + } + + const output = runCapture(command, { ignoreError: true }); + if (!output) { + switch (provider) { + case "vllm-local": + return { + ok: false, + message: "Local vLLM was selected, but nothing is responding on http://localhost:8000.", + }; + case "ollama-local": + return { + ok: false, + message: + "Local Ollama was selected, but nothing is responding on http://localhost:11434.", + }; + default: + return { ok: false, message: "The selected local inference provider is unavailable." }; + } + } + + const containerCommand = getLocalProviderContainerReachabilityCheck(provider); + if (!containerCommand) { + return { ok: true }; + } + + const containerOutput = runCapture(containerCommand, { ignoreError: true }); + if (containerOutput) { + return { ok: true }; + } + + switch (provider) { + case "vllm-local": + return { + ok: false, + message: + "Local vLLM is responding on localhost, but containers cannot reach http://host.openshell.internal:8000. Ensure the server is reachable from containers, not only from the host shell.", + }; + case "ollama-local": + return { + ok: false, + message: + "Local Ollama is responding on localhost, but containers cannot reach http://host.openshell.internal:11434. Ensure Ollama listens on 0.0.0.0:11434 instead of 127.0.0.1 so sandboxes can reach it.", + }; + default: + return { + ok: false, + message: "The selected local inference provider is unavailable from containers.", + }; + } +} + +export function parseOllamaList(output: unknown): string[] { + return String(output || "") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .filter((line) => !/^NAME\s+/i.test(line)) + .map((line) => line.split(/\s{2,}/)[0]) + .filter(Boolean); +} + +export function parseOllamaTags(output: unknown): string[] { + try { + const parsed = JSON.parse(String(output || "")); + return Array.isArray(parsed?.models) + ? parsed.models.map((model: { name?: string }) => model && model.name).filter(Boolean) + : []; + } catch { + return []; + } +} + +export function getOllamaModelOptions(runCapture: RunCaptureFn): string[] { + const tagsOutput = runCapture("curl -sf http://localhost:11434/api/tags 2>/dev/null", { + ignoreError: true, + }); + const tagsParsed = parseOllamaTags(tagsOutput); + if (tagsParsed.length > 0) { + return tagsParsed; + } + + const listOutput = runCapture("ollama list 2>/dev/null", { ignoreError: true }); + return parseOllamaList(listOutput); +} + +export function getBootstrapOllamaModelOptions(gpu: GpuInfo | null): string[] { + const options = [SMALL_OLLAMA_MODEL]; + if (gpu && gpu.totalMemoryMB >= LARGE_OLLAMA_MIN_MEMORY_MB) { + options.push(DEFAULT_OLLAMA_MODEL); + } + return options; +} + +export function getDefaultOllamaModel( + runCapture: RunCaptureFn, + gpu: GpuInfo | null = null, +): string { + const models = getOllamaModelOptions(runCapture); + if (models.length === 0) { + const bootstrap = getBootstrapOllamaModelOptions(gpu); + return bootstrap[0]; + } + return models.includes(DEFAULT_OLLAMA_MODEL) ? DEFAULT_OLLAMA_MODEL : models[0]; +} + +export function getOllamaWarmupCommand(model: string, keepAlive = "15m"): string { + const payload = JSON.stringify({ + model, + prompt: "hello", + stream: false, + keep_alive: keepAlive, + }); + return `nohup curl -s http://localhost:11434/api/generate -H 'Content-Type: application/json' -d ${shellQuote(payload)} >/dev/null 2>&1 &`; +} + +export function getOllamaProbeCommand( + model: string, + timeoutSeconds = 120, + keepAlive = "15m", +): string { + const payload = JSON.stringify({ + model, + prompt: "hello", + stream: false, + keep_alive: keepAlive, + }); + return `curl -sS --max-time ${timeoutSeconds} http://localhost:11434/api/generate -H 'Content-Type: application/json' -d ${shellQuote(payload)} 2>/dev/null`; +} + +export function validateOllamaModel( + model: string, + runCapture: RunCaptureFn, +): ValidationResult { + const output = runCapture(getOllamaProbeCommand(model), { ignoreError: true }); + if (!output) { + return { + ok: false, + message: + `Selected Ollama model '${model}' did not answer the local probe in time. ` + + "It may still be loading, too large for the host, or otherwise unhealthy.", + }; + } + + try { + const parsed = JSON.parse(output); + if (parsed && typeof parsed.error === "string" && parsed.error.trim()) { + return { + ok: false, + message: `Selected Ollama model '${model}' failed the local probe: ${parsed.error.trim()}`, + }; + } + } catch { + /* ignored */ + } + + return { ok: true }; +} diff --git a/src/lib/nim.test.ts b/src/lib/nim.test.ts new file mode 100644 index 000000000..ab3cbe5f9 --- /dev/null +++ b/src/lib/nim.test.ts @@ -0,0 +1,259 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createRequire } from "module"; +import { describe, it, expect, vi } from "vitest"; +import type { Mock } from "vitest"; + +// Import from compiled dist/ for coverage attribution. +import nim from "../../dist/lib/nim"; + +const require = createRequire(import.meta.url); +const NIM_DIST_PATH = require.resolve("../../dist/lib/nim"); +const RUNNER_PATH = require.resolve("../../bin/lib/runner"); + +function loadNimWithMockedRunner(runCapture: Mock) { + const runner = require(RUNNER_PATH); + const originalRun = runner.run; + const originalRunCapture = runner.runCapture; + + delete require.cache[NIM_DIST_PATH]; + runner.run = vi.fn(); + runner.runCapture = runCapture; + const nimModule = require(NIM_DIST_PATH); + + return { + nimModule, + restore() { + delete require.cache[NIM_DIST_PATH]; + runner.run = originalRun; + runner.runCapture = originalRunCapture; + }, + }; +} + +describe("nim", () => { + describe("listModels", () => { + it("returns 5 models", () => { + expect(nim.listModels().length).toBe(5); + }); + + it("each model has name, image, and minGpuMemoryMB", () => { + for (const m of nim.listModels()) { + expect(m.name).toBeTruthy(); + expect(m.image).toBeTruthy(); + expect(typeof m.minGpuMemoryMB === "number").toBeTruthy(); + expect(m.minGpuMemoryMB > 0).toBeTruthy(); + } + }); + }); + + describe("getImageForModel", () => { + it("returns correct image for known model", () => { + expect(nim.getImageForModel("nvidia/nemotron-3-nano-30b-a3b")).toBe( + "nvcr.io/nim/nvidia/nemotron-3-nano:latest", + ); + }); + + it("returns null for unknown model", () => { + expect(nim.getImageForModel("bogus/model")).toBe(null); + }); + }); + + describe("containerName", () => { + it("prefixes with nemoclaw-nim-", () => { + expect(nim.containerName("my-sandbox")).toBe("nemoclaw-nim-my-sandbox"); + }); + }); + + describe("detectGpu", () => { + it("returns object or null", () => { + const gpu = nim.detectGpu(); + if (gpu !== null) { + expect(gpu.type).toBeTruthy(); + expect(typeof gpu.count === "number").toBeTruthy(); + expect(typeof gpu.totalMemoryMB === "number").toBeTruthy(); + expect(typeof gpu.nimCapable === "boolean").toBeTruthy(); + } + }); + + it("nvidia type is nimCapable", () => { + const gpu = nim.detectGpu(); + if (gpu && gpu.type === "nvidia") { + expect(gpu.nimCapable).toBe(true); + } + }); + + it("apple type is not nimCapable", () => { + const gpu = nim.detectGpu(); + if (gpu && gpu.type === "apple") { + expect(gpu.nimCapable).toBe(false); + expect(gpu.name).toBeTruthy(); + } + }); + + it("detects GB10 unified-memory GPUs as Spark-capable NVIDIA devices", () => { + const runCapture = vi.fn((cmd: string) => { + if (cmd.includes("memory.total")) return ""; + if (cmd.includes("query-gpu=name")) return "NVIDIA GB10"; + if (cmd.includes("free -m")) return "131072"; + return ""; + }); + const { nimModule, restore } = loadNimWithMockedRunner(runCapture); + + try { + expect(nimModule.detectGpu()).toMatchObject({ + type: "nvidia", + name: "NVIDIA GB10", + count: 1, + totalMemoryMB: 131072, + perGpuMB: 131072, + nimCapable: true, + unifiedMemory: true, + spark: true, + }); + } finally { + restore(); + } + }); + + it("detects Orin unified-memory GPUs without marking them as Spark", () => { + const runCapture = vi.fn((cmd: string) => { + if (cmd.includes("memory.total")) return ""; + if (cmd.includes("query-gpu=name")) return "NVIDIA Jetson AGX Orin"; + if (cmd.includes("free -m")) return "32768"; + return ""; + }); + const { nimModule, restore } = loadNimWithMockedRunner(runCapture); + + try { + expect(nimModule.detectGpu()).toMatchObject({ + type: "nvidia", + name: "NVIDIA Jetson AGX Orin", + count: 1, + totalMemoryMB: 32768, + perGpuMB: 32768, + nimCapable: true, + unifiedMemory: true, + spark: false, + }); + } finally { + restore(); + } + }); + + it("marks low-memory unified-memory NVIDIA devices as not NIM-capable", () => { + const runCapture = vi.fn((cmd: string) => { + if (cmd.includes("memory.total")) return ""; + if (cmd.includes("query-gpu=name")) return "NVIDIA Xavier"; + if (cmd.includes("free -m")) return "4096"; + return ""; + }); + const { nimModule, restore } = loadNimWithMockedRunner(runCapture); + + try { + expect(nimModule.detectGpu()).toMatchObject({ + type: "nvidia", + name: "NVIDIA Xavier", + totalMemoryMB: 4096, + nimCapable: false, + unifiedMemory: true, + spark: false, + }); + } finally { + restore(); + } + }); + }); + + describe("nimStatus", () => { + it("returns not running for nonexistent container", () => { + const st = nim.nimStatus("nonexistent-test-xyz"); + expect(st.running).toBe(false); + }); + }); + + describe("nimStatusByName", () => { + it("uses provided port directly", () => { + const runCapture = vi.fn((cmd: string) => { + if (cmd.includes("docker inspect")) return "running"; + if (cmd.includes("http://localhost:9000/v1/models")) return '{"data":[]}'; + return ""; + }); + const { nimModule, restore } = loadNimWithMockedRunner(runCapture); + + try { + const st = nimModule.nimStatusByName("foo", 9000); + const commands = runCapture.mock.calls.map(([cmd]: [string]) => cmd); + + expect(st).toMatchObject({ + running: true, + healthy: true, + container: "foo", + state: "running", + }); + expect(commands.some((cmd: string) => cmd.includes("docker port"))).toBe(false); + expect(commands.some((cmd: string) => cmd.includes("http://localhost:9000/v1/models"))).toBe( + true, + ); + } finally { + restore(); + } + }); + + it("uses published docker port when no port is provided", () => { + for (const mapping of ["0.0.0.0:9000", "127.0.0.1:9000", "[::]:9000", ":::9000"]) { + const runCapture = vi.fn((cmd: string) => { + if (cmd.includes("docker inspect")) return "running"; + if (cmd.includes("docker port")) return mapping; + if (cmd.includes("http://localhost:9000/v1/models")) return '{"data":[]}'; + return ""; + }); + const { nimModule, restore } = loadNimWithMockedRunner(runCapture); + + try { + const st = nimModule.nimStatusByName("foo"); + const commands = runCapture.mock.calls.map(([cmd]: [string]) => cmd); + + expect(st).toMatchObject({ running: true, healthy: true, container: "foo", state: "running" }); + expect(commands.some((cmd: string) => cmd.includes("docker port"))).toBe(true); + } finally { + restore(); + } + } + }); + + it("falls back to 8000 when docker port lookup fails", () => { + const runCapture = vi.fn((cmd: string) => { + if (cmd.includes("docker inspect")) return "running"; + if (cmd.includes("docker port")) return ""; + if (cmd.includes("http://localhost:8000/v1/models")) return '{"data":[]}'; + return ""; + }); + const { nimModule, restore } = loadNimWithMockedRunner(runCapture); + + try { + const st = nimModule.nimStatusByName("foo"); + expect(st).toMatchObject({ running: true, healthy: true, container: "foo", state: "running" }); + } finally { + restore(); + } + }); + + it("does not run health check when container is not running", () => { + const runCapture = vi.fn((cmd: string) => { + if (cmd.includes("docker inspect")) return "exited"; + return ""; + }); + const { nimModule, restore } = loadNimWithMockedRunner(runCapture); + + try { + const st = nimModule.nimStatusByName("foo"); + expect(st).toMatchObject({ running: false, healthy: false, container: "foo", state: "exited" }); + expect(runCapture.mock.calls).toHaveLength(1); + } finally { + restore(); + } + }); + }); +}); diff --git a/src/lib/nim.ts b/src/lib/nim.ts new file mode 100644 index 000000000..2e10d1e60 --- /dev/null +++ b/src/lib/nim.ts @@ -0,0 +1,275 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// NIM container management — pull, start, stop, health-check NIM images. + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { run, runCapture, shellQuote } = require("../../bin/lib/runner"); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const nimImages = require("../../bin/lib/nim-images.json"); + +const UNIFIED_MEMORY_GPU_TAGS = ["GB10", "Thor", "Orin", "Xavier"]; + +export interface NimModel { + name: string; + image: string; + minGpuMemoryMB: number; +} + +export interface GpuDetection { + type: string; + name?: string; + count: number; + totalMemoryMB: number; + perGpuMB: number; + cores?: number | null; + nimCapable: boolean; + unifiedMemory?: boolean; + spark?: boolean; +} + +export interface NimStatus { + running: boolean; + healthy?: boolean; + container: string; + state?: string; +} + +export function containerName(sandboxName: string): string { + return `nemoclaw-nim-${sandboxName}`; +} + +export function getImageForModel(modelName: string): string | null { + const entry = nimImages.models.find((m: NimModel) => m.name === modelName); + return entry ? entry.image : null; +} + +export function listModels(): NimModel[] { + return nimImages.models.map((m: NimModel) => ({ + name: m.name, + image: m.image, + minGpuMemoryMB: m.minGpuMemoryMB, + })); +} + +export function canRunNimWithMemory(totalMemoryMB: number): boolean { + return nimImages.models.some((m: NimModel) => m.minGpuMemoryMB <= totalMemoryMB); +} + +export function detectGpu(): GpuDetection | null { + // Try NVIDIA first — query VRAM + try { + const output = runCapture( + "nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits", + { ignoreError: true }, + ); + if (output) { + const lines = output.split("\n").filter((l: string) => l.trim()); + const perGpuMB = lines + .map((l: string) => parseInt(l.trim(), 10)) + .filter((n: number) => !isNaN(n)); + if (perGpuMB.length > 0) { + const totalMemoryMB = perGpuMB.reduce((a: number, b: number) => a + b, 0); + return { + type: "nvidia", + count: perGpuMB.length, + totalMemoryMB, + perGpuMB: perGpuMB[0], + nimCapable: canRunNimWithMemory(totalMemoryMB), + }; + } + } + } catch { + /* ignored */ + } + + // Fallback: unified-memory NVIDIA devices + try { + const nameOutput = runCapture( + "nvidia-smi --query-gpu=name --format=csv,noheader,nounits", + { ignoreError: true }, + ); + const gpuNames = nameOutput + .split("\n") + .map((line: string) => line.trim()) + .filter(Boolean); + const unifiedGpuNames = gpuNames.filter((name: string) => + UNIFIED_MEMORY_GPU_TAGS.some((tag) => new RegExp(tag, "i").test(name)), + ); + if (unifiedGpuNames.length > 0) { + let totalMemoryMB = 0; + try { + const memLine = runCapture("free -m | awk '/Mem:/ {print $2}'", { ignoreError: true }); + if (memLine) totalMemoryMB = parseInt(memLine.trim(), 10) || 0; + } catch { + /* ignored */ + } + const count = unifiedGpuNames.length; + const perGpuMB = count > 0 ? Math.floor(totalMemoryMB / count) : totalMemoryMB; + const isSpark = unifiedGpuNames.some((name: string) => /GB10/i.test(name)); + return { + type: "nvidia", + name: unifiedGpuNames[0], + count, + totalMemoryMB, + perGpuMB: perGpuMB || totalMemoryMB, + nimCapable: canRunNimWithMemory(totalMemoryMB), + unifiedMemory: true, + spark: isSpark, + }; + } + } catch { + /* ignored */ + } + + // macOS: detect Apple Silicon or discrete GPU + if (process.platform === "darwin") { + try { + const spOutput = runCapture("system_profiler SPDisplaysDataType 2>/dev/null", { + ignoreError: true, + }); + if (spOutput) { + const chipMatch = spOutput.match(/Chipset Model:\s*(.+)/); + const vramMatch = spOutput.match(/VRAM.*?:\s*(\d+)\s*(MB|GB)/i); + const coresMatch = spOutput.match(/Total Number of Cores:\s*(\d+)/); + + if (chipMatch) { + const name = chipMatch[1].trim(); + let memoryMB = 0; + + if (vramMatch) { + memoryMB = parseInt(vramMatch[1], 10); + if (vramMatch[2].toUpperCase() === "GB") memoryMB *= 1024; + } else { + try { + const memBytes = runCapture("sysctl -n hw.memsize", { ignoreError: true }); + if (memBytes) memoryMB = Math.floor(parseInt(memBytes, 10) / 1024 / 1024); + } catch { + /* ignored */ + } + } + + return { + type: "apple", + name, + count: 1, + cores: coresMatch ? parseInt(coresMatch[1], 10) : null, + totalMemoryMB: memoryMB, + perGpuMB: memoryMB, + nimCapable: false, + }; + } + } + } catch { + /* ignored */ + } + } + + return null; +} + +export function pullNimImage(model: string): string { + const image = getImageForModel(model); + if (!image) { + console.error(` Unknown model: ${model}`); + process.exit(1); + } + console.log(` Pulling NIM image: ${image}`); + run(`docker pull ${shellQuote(image)}`); + return image; +} + +export function startNimContainer(sandboxName: string, model: string, port = 8000): string { + const name = containerName(sandboxName); + return startNimContainerByName(name, model, port); +} + +export function startNimContainerByName(name: string, model: string, port = 8000): string { + const image = getImageForModel(model); + if (!image) { + console.error(` Unknown model: ${model}`); + process.exit(1); + } + + const qn = shellQuote(name); + run(`docker rm -f ${qn} 2>/dev/null || true`, { ignoreError: true }); + + console.log(` Starting NIM container: ${name}`); + run( + `docker run -d --gpus all -p ${Number(port)}:8000 --name ${qn} --shm-size 16g ${shellQuote(image)}`, + ); + return name; +} + +export function waitForNimHealth(port = 8000, timeout = 300): boolean { + const start = Date.now(); + const intervalSec = 5; + const hostPort = Number(port); + console.log(` Waiting for NIM health on port ${hostPort} (timeout: ${timeout}s)...`); + + while ((Date.now() - start) / 1000 < timeout) { + try { + const result = runCapture(`curl -sf http://localhost:${hostPort}/v1/models`, { + ignoreError: true, + }); + if (result) { + console.log(" NIM is healthy."); + return true; + } + } catch { + /* ignored */ + } + // eslint-disable-next-line @typescript-eslint/no-require-imports + require("child_process").spawnSync("sleep", [String(intervalSec)]); + } + console.error(` NIM did not become healthy within ${timeout}s.`); + return false; +} + +export function stopNimContainer(sandboxName: string): void { + const name = containerName(sandboxName); + stopNimContainerByName(name); +} + +export function stopNimContainerByName(name: string): void { + const qn = shellQuote(name); + console.log(` Stopping NIM container: ${name}`); + run(`docker stop ${qn} 2>/dev/null || true`, { ignoreError: true }); + run(`docker rm ${qn} 2>/dev/null || true`, { ignoreError: true }); +} + +export function nimStatus(sandboxName: string, port?: number): NimStatus { + const name = containerName(sandboxName); + return nimStatusByName(name, port); +} + +export function nimStatusByName(name: string, port?: number): NimStatus { + try { + const qn = shellQuote(name); + const state = runCapture( + `docker inspect --format '{{.State.Status}}' ${qn} 2>/dev/null`, + { ignoreError: true }, + ); + if (!state) return { running: false, container: name }; + + let healthy = false; + if (state === "running") { + let resolvedHostPort = port != null ? Number(port) : 0; + if (!resolvedHostPort) { + const mapping = runCapture(`docker port ${qn} 8000 2>/dev/null`, { + ignoreError: true, + }); + const m = mapping && mapping.match(/:(\d+)\s*$/); + resolvedHostPort = m ? Number(m[1]) : 8000; + } + const health = runCapture( + `curl -sf http://localhost:${resolvedHostPort}/v1/models 2>/dev/null`, + { ignoreError: true }, + ); + healthy = !!health; + } + return { running: state === "running", healthy, container: name, state }; + } catch { + return { running: false, container: name }; + } +} diff --git a/src/lib/onboard-session.test.ts b/src/lib/onboard-session.test.ts new file mode 100644 index 000000000..6156a574e --- /dev/null +++ b/src/lib/onboard-session.test.ts @@ -0,0 +1,231 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; + +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-session-")); +const require = createRequire(import.meta.url); +// Clear both the shim and the dist module so HOME changes take effect. +const shimPath = require.resolve("../../bin/lib/onboard-session"); +const distPath = require.resolve("../../dist/lib/onboard-session"); +const originalHome = process.env.HOME; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let session: any; + +beforeEach(() => { + process.env.HOME = tmpDir; + delete require.cache[shimPath]; + delete require.cache[distPath]; + session = require("../../dist/lib/onboard-session"); + session.clearSession(); + session.releaseOnboardLock(); +}); + +afterEach(() => { + delete require.cache[shimPath]; + delete require.cache[distPath]; + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } +}); + +describe("onboard session", () => { + it("starts empty", () => { + expect(session.loadSession()).toBeNull(); + }); + + it("creates and persists a session with restrictive permissions", () => { + const created = session.createSession({ mode: "non-interactive" }); + const saved = session.saveSession(created); + const stat = fs.statSync(session.SESSION_FILE); + const dirStat = fs.statSync(path.dirname(session.SESSION_FILE)); + + expect(saved.mode).toBe("non-interactive"); + expect(fs.existsSync(session.SESSION_FILE)).toBe(true); + expect(stat.mode & 0o777).toBe(0o600); + expect(dirStat.mode & 0o777).toBe(0o700); + }); + + it("redacts credential-bearing endpoint URLs before persisting them", () => { + session.saveSession(session.createSession()); + session.markStepComplete("provider_selection", { + endpointUrl: + "https://alice:secret@example.com/v1/models?token=abc123&sig=def456&X-Amz-Signature=ghi789&keep=yes#token=frag", + }); + + const loaded = session.loadSession(); + expect(loaded.endpointUrl).toBe( + "https://example.com/v1/models?token=%3CREDACTED%3E&sig=%3CREDACTED%3E&X-Amz-Signature=%3CREDACTED%3E&keep=yes", + ); + expect(session.summarizeForDebug().endpointUrl).toBe(loaded.endpointUrl); + }); + + it("marks steps started, completed, and failed", () => { + session.saveSession(session.createSession()); + session.markStepStarted("gateway"); + let loaded = session.loadSession(); + expect(loaded.steps.gateway.status).toBe("in_progress"); + expect(loaded.lastStepStarted).toBe("gateway"); + expect(loaded.steps.gateway.completedAt).toBeNull(); + + session.markStepComplete("gateway", { sandboxName: "my-assistant" }); + loaded = session.loadSession(); + expect(loaded.steps.gateway.status).toBe("complete"); + expect(loaded.sandboxName).toBe("my-assistant"); + expect(loaded.steps.gateway.completedAt).toBeTruthy(); + + session.markStepFailed("sandbox", "Sandbox creation failed"); + loaded = session.loadSession(); + expect(loaded.steps.sandbox.status).toBe("failed"); + expect(loaded.steps.sandbox.completedAt).toBeNull(); + expect(loaded.failure.step).toBe("sandbox"); + expect(loaded.failure.message).toMatch(/Sandbox creation failed/); + }); + + it("persists safe provider metadata without persisting secrets", () => { + session.saveSession(session.createSession()); + session.markStepComplete("provider_selection", { + provider: "nvidia-nim", + model: "nvidia/test-model", + sandboxName: "my-assistant", + endpointUrl: "https://example.com/v1", + credentialEnv: "NVIDIA_API_KEY", + preferredInferenceApi: "openai-completions", + nimContainer: "nim-123", + policyPresets: ["pypi", "npm"], + apiKey: "nvapi-secret", + metadata: { + gatewayName: "nemoclaw", + token: "secret", + }, + }); + + const loaded = session.loadSession(); + expect(loaded.provider).toBe("nvidia-nim"); + expect(loaded.model).toBe("nvidia/test-model"); + expect(loaded.sandboxName).toBe("my-assistant"); + expect(loaded.endpointUrl).toBe("https://example.com/v1"); + expect(loaded.credentialEnv).toBe("NVIDIA_API_KEY"); + expect(loaded.preferredInferenceApi).toBe("openai-completions"); + expect(loaded.nimContainer).toBe("nim-123"); + expect(loaded.policyPresets).toEqual(["pypi", "npm"]); + expect(loaded.apiKey).toBeUndefined(); + expect(loaded.metadata.gatewayName).toBe("nemoclaw"); + expect(loaded.metadata.token).toBeUndefined(); + }); + + it("does not clear existing metadata when updates omit whitelisted metadata fields", () => { + session.saveSession(session.createSession({ metadata: { gatewayName: "nemoclaw" } })); + session.markStepComplete("provider_selection", { + metadata: { + token: "should-not-persist", + }, + }); + + const loaded = session.loadSession(); + expect(loaded.metadata.gatewayName).toBe("nemoclaw"); + expect(loaded.metadata.token).toBeUndefined(); + }); + + it("returns null for corrupt session data", () => { + fs.mkdirSync(path.dirname(session.SESSION_FILE), { recursive: true }); + fs.writeFileSync(session.SESSION_FILE, "not-json"); + expect(session.loadSession()).toBeNull(); + }); + + it("acquires and releases the onboard lock", () => { + const acquired = session.acquireOnboardLock("nemoclaw onboard"); + expect(acquired.acquired).toBe(true); + expect(fs.existsSync(session.LOCK_FILE)).toBe(true); + + const secondAttempt = session.acquireOnboardLock("nemoclaw onboard --resume"); + expect(secondAttempt.acquired).toBe(false); + expect(secondAttempt.holderPid).toBe(process.pid); + + session.releaseOnboardLock(); + expect(fs.existsSync(session.LOCK_FILE)).toBe(false); + }); + + it("replaces a stale onboard lock", () => { + fs.mkdirSync(path.dirname(session.LOCK_FILE), { recursive: true }); + fs.writeFileSync( + session.LOCK_FILE, + JSON.stringify({ + pid: 999999, + startedAt: "2026-03-25T00:00:00.000Z", + command: "nemoclaw onboard", + }), + { mode: 0o600 }, + ); + + const acquired = session.acquireOnboardLock("nemoclaw onboard --resume"); + expect(acquired.acquired).toBe(true); + + const written = JSON.parse(fs.readFileSync(session.LOCK_FILE, "utf8")); + expect(written.pid).toBe(process.pid); + }); + + it("treats unreadable or transient lock contents as a retry, not a stale lock", () => { + fs.mkdirSync(path.dirname(session.LOCK_FILE), { recursive: true }); + fs.writeFileSync(session.LOCK_FILE, "{not-json", { mode: 0o600 }); + + const acquired = session.acquireOnboardLock("nemoclaw onboard --resume"); + expect(acquired.acquired).toBe(false); + expect(acquired.stale).toBe(true); + expect(fs.existsSync(session.LOCK_FILE)).toBe(true); + }); + + it("ignores malformed lock files when releasing the onboard lock", () => { + fs.mkdirSync(path.dirname(session.LOCK_FILE), { recursive: true }); + fs.writeFileSync(session.LOCK_FILE, "{not-json", { mode: 0o600 }); + + session.releaseOnboardLock(); + expect(fs.existsSync(session.LOCK_FILE)).toBe(true); + }); + + it("redacts sensitive values from persisted failure messages", () => { + session.saveSession(session.createSession()); + session.markStepFailed( + "inference", + "provider auth failed with NVIDIA_API_KEY=nvapi-secret Bearer topsecret sk-secret-value ghp_1234567890123456789012345", + ); + + const loaded = session.loadSession(); + expect(loaded.steps.inference.error).toContain("NVIDIA_API_KEY="); + expect(loaded.steps.inference.error).toContain("Bearer "); + expect(loaded.steps.inference.error).not.toContain("nvapi-secret"); + expect(loaded.steps.inference.error).not.toContain("topsecret"); + expect(loaded.steps.inference.error).not.toContain("sk-secret-value"); + expect(loaded.steps.inference.error).not.toContain("ghp_1234567890123456789012345"); + expect(loaded.failure.message).toBe(loaded.steps.inference.error); + }); + + it("summarizes the session for debug output", () => { + session.saveSession(session.createSession({ sandboxName: "my-assistant" })); + session.markStepStarted("preflight"); + session.markStepComplete("preflight"); + session.completeSession(); + const summary = session.summarizeForDebug(); + + expect(summary.sandboxName).toBe("my-assistant"); + expect(summary.steps.preflight.status).toBe("complete"); + expect(summary.steps.preflight.startedAt).toBeTruthy(); + expect(summary.steps.preflight.completedAt).toBeTruthy(); + expect(summary.resumable).toBe(false); + }); + + it("keeps debug summaries redacted when failures were sanitized", () => { + session.saveSession(session.createSession({ sandboxName: "my-assistant" })); + session.markStepFailed("provider_selection", "Bearer abcdefghijklmnopqrstuvwxyz"); + const summary = session.summarizeForDebug(); + + expect(summary.failure.message).toContain("Bearer "); + expect(summary.failure.message).not.toContain("abcdefghijklmnopqrstuvwxyz"); + }); +}); diff --git a/src/lib/onboard-session.ts b/src/lib/onboard-session.ts new file mode 100644 index 000000000..ff2de5310 --- /dev/null +++ b/src/lib/onboard-session.ts @@ -0,0 +1,512 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Onboard session management — create, load, save, and update the + * onboarding session file (~/.nemoclaw/onboard-session.json) with + * step-level progress tracking and file-based locking. + */ + +import fs from "node:fs"; +import path from "node:path"; + +export const SESSION_VERSION = 1; +export const SESSION_DIR = path.join(process.env.HOME || "/tmp", ".nemoclaw"); +export const SESSION_FILE = path.join(SESSION_DIR, "onboard-session.json"); +export const LOCK_FILE = path.join(SESSION_DIR, "onboard.lock"); +const VALID_STEP_STATES = new Set(["pending", "in_progress", "complete", "failed", "skipped"]); + +// ── Types ──────────────────────────────────────────────────────── + +export interface StepState { + status: string; + startedAt: string | null; + completedAt: string | null; + error: string | null; +} + +export interface SessionFailure { + step: string | null; + message: string | null; + recordedAt: string; +} + +export interface SessionMetadata { + gatewayName: string; +} + +export interface Session { + version: number; + sessionId: string; + resumable: boolean; + status: string; + mode: string; + startedAt: string; + updatedAt: string; + lastStepStarted: string | null; + lastCompletedStep: string | null; + failure: SessionFailure | null; + sandboxName: string | null; + provider: string | null; + model: string | null; + endpointUrl: string | null; + credentialEnv: string | null; + preferredInferenceApi: string | null; + nimContainer: string | null; + policyPresets: string[] | null; + metadata: SessionMetadata; + steps: Record; +} + +export interface LockInfo { + pid: number; + startedAt: string | null; + command: string | null; +} + +export interface LockResult { + acquired: boolean; + lockFile: string; + stale: boolean; + holderPid?: number; + holderStartedAt?: string | null; + holderCommand?: string | null; +} + +export interface SessionUpdates { + sandboxName?: string; + provider?: string; + model?: string; + endpointUrl?: string; + credentialEnv?: string; + preferredInferenceApi?: string; + nimContainer?: string; + policyPresets?: string[]; + metadata?: { gatewayName?: string }; +} + +// ── Helpers ────────────────────────────────────────────────────── + +function ensureSessionDir(): void { + fs.mkdirSync(SESSION_DIR, { recursive: true, mode: 0o700 }); +} + +export function sessionPath(): string { + return SESSION_FILE; +} + +export function lockPath(): string { + return LOCK_FILE; +} + +function defaultSteps(): Record { + return { + preflight: { status: "pending", startedAt: null, completedAt: null, error: null }, + gateway: { status: "pending", startedAt: null, completedAt: null, error: null }, + sandbox: { status: "pending", startedAt: null, completedAt: null, error: null }, + provider_selection: { status: "pending", startedAt: null, completedAt: null, error: null }, + inference: { status: "pending", startedAt: null, completedAt: null, error: null }, + openclaw: { status: "pending", startedAt: null, completedAt: null, error: null }, + policies: { status: "pending", startedAt: null, completedAt: null, error: null }, + }; +} + +export function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function redactSensitiveText(value: unknown): string | null { + if (typeof value !== "string") return null; + return value + .replace( + /(NVIDIA_API_KEY|OPENAI_API_KEY|ANTHROPIC_API_KEY|GEMINI_API_KEY|COMPATIBLE_API_KEY|COMPATIBLE_ANTHROPIC_API_KEY)=\S+/gi, + "$1=", + ) + .replace(/Bearer\s+\S+/gi, "Bearer ") + .replace(/nvapi-[A-Za-z0-9_-]{10,}/g, "") + .replace(/ghp_[A-Za-z0-9]{20,}/g, "") + .replace(/sk-[A-Za-z0-9_-]{10,}/g, "") + .slice(0, 240); +} + +export function sanitizeFailure( + input: { step?: unknown; message?: unknown; recordedAt?: unknown } | null | undefined, +): SessionFailure | null { + if (!input) return null; + const step = typeof input.step === "string" ? input.step : null; + const message = redactSensitiveText(input.message); + const recordedAt = + typeof input.recordedAt === "string" ? input.recordedAt : new Date().toISOString(); + return step || message ? { step, message, recordedAt } : null; +} + +export function validateStep(step: unknown): boolean { + if (!isObject(step)) return false; + if (!VALID_STEP_STATES.has(step.status as string)) return false; + return true; +} + +export function redactUrl(value: unknown): string | null { + if (typeof value !== "string" || value.length === 0) return null; + try { + const url = new URL(value); + if (url.username || url.password) { + url.username = ""; + url.password = ""; + } + for (const key of [...url.searchParams.keys()]) { + if (/(^|[-_])(?:signature|sig|token|auth|access_token)$/i.test(key)) { + url.searchParams.set(key, ""); + } + } + url.hash = ""; + return url.toString(); + } catch { + return redactSensitiveText(value); + } +} + +// ── Session CRUD ───────────────────────────────────────────────── + +export function createSession(overrides: Partial = {}): Session { + const now = new Date().toISOString(); + return { + version: SESSION_VERSION, + sessionId: overrides.sessionId || `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, + resumable: true, + status: "in_progress", + mode: overrides.mode || "interactive", + startedAt: overrides.startedAt || now, + updatedAt: overrides.updatedAt || now, + lastStepStarted: overrides.lastStepStarted || null, + lastCompletedStep: overrides.lastCompletedStep || null, + failure: overrides.failure || null, + sandboxName: overrides.sandboxName || null, + provider: overrides.provider || null, + model: overrides.model || null, + endpointUrl: overrides.endpointUrl || null, + credentialEnv: overrides.credentialEnv || null, + preferredInferenceApi: overrides.preferredInferenceApi || null, + nimContainer: overrides.nimContainer || null, + policyPresets: Array.isArray(overrides.policyPresets) + ? overrides.policyPresets.filter((value) => typeof value === "string") + : null, + metadata: { + gatewayName: overrides.metadata?.gatewayName || "nemoclaw", + }, + steps: { + ...defaultSteps(), + ...(overrides.steps || {}), + }, + }; +} + +// eslint-disable-next-line complexity +export function normalizeSession(data: unknown): Session | null { + if (!isObject(data) || (data as Record).version !== SESSION_VERSION) return null; + const d = data as Record; + const normalized = createSession({ + sessionId: typeof d.sessionId === "string" ? d.sessionId : undefined, + mode: typeof d.mode === "string" ? d.mode : undefined, + startedAt: typeof d.startedAt === "string" ? d.startedAt : undefined, + updatedAt: typeof d.updatedAt === "string" ? d.updatedAt : undefined, + sandboxName: typeof d.sandboxName === "string" ? d.sandboxName : null, + provider: typeof d.provider === "string" ? d.provider : null, + model: typeof d.model === "string" ? d.model : null, + endpointUrl: typeof d.endpointUrl === "string" ? redactUrl(d.endpointUrl) : null, + credentialEnv: typeof d.credentialEnv === "string" ? d.credentialEnv : null, + preferredInferenceApi: + typeof d.preferredInferenceApi === "string" ? d.preferredInferenceApi : null, + nimContainer: typeof d.nimContainer === "string" ? d.nimContainer : null, + policyPresets: Array.isArray(d.policyPresets) + ? (d.policyPresets as unknown[]).filter((value) => typeof value === "string") as string[] + : null, + lastStepStarted: typeof d.lastStepStarted === "string" ? d.lastStepStarted : null, + lastCompletedStep: typeof d.lastCompletedStep === "string" ? d.lastCompletedStep : null, + failure: sanitizeFailure(d.failure as Record | null), + metadata: isObject(d.metadata) + ? ({ gatewayName: (d.metadata as Record).gatewayName } as SessionMetadata) + : undefined, + } as Partial); + normalized.resumable = d.resumable !== false; + normalized.status = typeof d.status === "string" ? d.status : normalized.status; + + if (isObject(d.steps)) { + for (const [name, step] of Object.entries(d.steps as Record)) { + if ( + Object.prototype.hasOwnProperty.call(normalized.steps, name) && + validateStep(step) + ) { + const s = step as Record; + normalized.steps[name] = { + status: s.status as string, + startedAt: typeof s.startedAt === "string" ? s.startedAt : null, + completedAt: typeof s.completedAt === "string" ? s.completedAt : null, + error: redactSensitiveText(s.error), + }; + } + } + } + + return normalized; +} + +export function loadSession(): Session | null { + try { + if (!fs.existsSync(SESSION_FILE)) { + return null; + } + const parsed = JSON.parse(fs.readFileSync(SESSION_FILE, "utf-8")); + return normalizeSession(parsed); + } catch { + return null; + } +} + +export function saveSession(session: Session): Session { + const normalized = normalizeSession(session) || createSession(); + normalized.updatedAt = new Date().toISOString(); + ensureSessionDir(); + const tmpFile = path.join( + SESSION_DIR, + `.onboard-session.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`, + ); + fs.writeFileSync(tmpFile, JSON.stringify(normalized, null, 2), { mode: 0o600 }); + fs.renameSync(tmpFile, SESSION_FILE); + return normalized; +} + +export function clearSession(): void { + try { + if (fs.existsSync(SESSION_FILE)) { + fs.unlinkSync(SESSION_FILE); + } + } catch { + return; + } +} + +// ── Locking ────────────────────────────────────────────────────── + +function parseLockFile(contents: string): LockInfo | null { + try { + const parsed = JSON.parse(contents); + if (typeof parsed?.pid !== "number") return null; + return { + pid: parsed.pid, + startedAt: typeof parsed.startedAt === "string" ? parsed.startedAt : null, + command: typeof parsed.command === "string" ? parsed.command : null, + }; + } catch { + return null; + } +} + +function isProcessAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error: unknown) { + return (error as NodeJS.ErrnoException)?.code === "EPERM"; + } +} + +export function acquireOnboardLock(command: string | null = null): LockResult { + ensureSessionDir(); + const payload = JSON.stringify( + { + pid: process.pid, + startedAt: new Date().toISOString(), + command: typeof command === "string" ? command : null, + }, + null, + 2, + ); + + for (let attempt = 0; attempt < 2; attempt++) { + try { + fs.writeFileSync(LOCK_FILE, payload, { flag: "wx", mode: 0o600 }); + return { acquired: true, lockFile: LOCK_FILE, stale: false }; + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException)?.code !== "EEXIST") { + throw error; + } + + let existing: LockInfo | null; + try { + existing = parseLockFile(fs.readFileSync(LOCK_FILE, "utf8")); + } catch (readError: unknown) { + if ((readError as NodeJS.ErrnoException)?.code === "ENOENT") { + continue; + } + throw readError; + } + if (!existing) { + continue; + } + if (existing && isProcessAlive(existing.pid)) { + return { + acquired: false, + lockFile: LOCK_FILE, + stale: false, + holderPid: existing.pid, + holderStartedAt: existing.startedAt, + holderCommand: existing.command, + }; + } + + try { + fs.unlinkSync(LOCK_FILE); + } catch (unlinkError: unknown) { + if ((unlinkError as NodeJS.ErrnoException)?.code !== "ENOENT") { + throw unlinkError; + } + } + } + } + + return { acquired: false, lockFile: LOCK_FILE, stale: true }; +} + +export function releaseOnboardLock(): void { + try { + if (!fs.existsSync(LOCK_FILE)) return; + let existing: LockInfo | null = null; + try { + existing = parseLockFile(fs.readFileSync(LOCK_FILE, "utf8")); + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return; + throw error; + } + if (!existing) return; + if (existing.pid !== process.pid) return; + fs.unlinkSync(LOCK_FILE); + } catch { + return; + } +} + +// ── Step management ────────────────────────────────────────────── + +export function filterSafeUpdates(updates: SessionUpdates): Partial { + const safe: Partial = {}; + if (!isObject(updates)) return safe; + if (typeof updates.sandboxName === "string") safe.sandboxName = updates.sandboxName; + if (typeof updates.provider === "string") safe.provider = updates.provider; + if (typeof updates.model === "string") safe.model = updates.model; + if (typeof updates.endpointUrl === "string") safe.endpointUrl = redactUrl(updates.endpointUrl); + if (typeof updates.credentialEnv === "string") safe.credentialEnv = updates.credentialEnv; + if (typeof updates.preferredInferenceApi === "string") + safe.preferredInferenceApi = updates.preferredInferenceApi; + if (typeof updates.nimContainer === "string") safe.nimContainer = updates.nimContainer; + if (Array.isArray(updates.policyPresets)) { + safe.policyPresets = updates.policyPresets.filter((value) => typeof value === "string"); + } + if (isObject(updates.metadata) && typeof updates.metadata.gatewayName === "string") { + safe.metadata = { + gatewayName: updates.metadata.gatewayName, + }; + } + return safe; +} + +export function updateSession(mutator: (session: Session) => Session | void): Session { + const current = loadSession() || createSession(); + const next = typeof mutator === "function" ? mutator(current) || current : current; + return saveSession(next); +} + +export function markStepStarted(stepName: string): Session { + return updateSession((session) => { + const step = session.steps[stepName]; + if (!step) return session; + step.status = "in_progress"; + step.startedAt = new Date().toISOString(); + step.completedAt = null; + step.error = null; + session.lastStepStarted = stepName; + session.failure = null; + session.status = "in_progress"; + return session; + }); +} + +export function markStepComplete(stepName: string, updates: SessionUpdates = {}): Session { + return updateSession((session) => { + const step = session.steps[stepName]; + if (!step) return session; + step.status = "complete"; + step.completedAt = new Date().toISOString(); + step.error = null; + session.lastCompletedStep = stepName; + session.failure = null; + Object.assign(session, filterSafeUpdates(updates)); + return session; + }); +} + +export function markStepFailed(stepName: string, message: string | null = null): Session { + return updateSession((session) => { + const step = session.steps[stepName]; + if (!step) return session; + step.status = "failed"; + step.completedAt = null; + step.error = redactSensitiveText(message); + session.failure = sanitizeFailure({ + step: stepName, + message, + recordedAt: new Date().toISOString(), + }); + session.status = "failed"; + return session; + }); +} + +export function completeSession(updates: SessionUpdates = {}): Session { + return updateSession((session) => { + Object.assign(session, filterSafeUpdates(updates)); + session.status = "complete"; + session.resumable = false; + session.failure = null; + return session; + }); +} + +export function summarizeForDebug(session: Session | null = loadSession()): Record< + string, + unknown +> | null { + if (!session) return null; + return { + version: session.version, + sessionId: session.sessionId, + status: session.status, + resumable: session.resumable, + mode: session.mode, + startedAt: session.startedAt, + updatedAt: session.updatedAt, + sandboxName: session.sandboxName, + provider: session.provider, + model: session.model, + endpointUrl: redactUrl(session.endpointUrl), + credentialEnv: session.credentialEnv, + preferredInferenceApi: session.preferredInferenceApi, + nimContainer: session.nimContainer, + policyPresets: session.policyPresets, + lastStepStarted: session.lastStepStarted, + lastCompletedStep: session.lastCompletedStep, + failure: session.failure, + steps: Object.fromEntries( + Object.entries(session.steps).map(([name, step]) => [ + name, + { + status: step.status, + startedAt: step.startedAt, + completedAt: step.completedAt, + error: step.error, + }, + ]), + ), + }; +} diff --git a/src/lib/preflight.test.ts b/src/lib/preflight.test.ts new file mode 100644 index 000000000..a14102c5e --- /dev/null +++ b/src/lib/preflight.test.ts @@ -0,0 +1,340 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +// Import through the compiled dist/ output (via the bin/lib shim) so +// coverage is attributed to dist/lib/preflight.js, which is what the +// ratchet measures. +import { + checkPortAvailable, + getMemoryInfo, + ensureSwap, +} from "../../dist/lib/preflight"; + +describe("checkPortAvailable", () => { + it("falls through to the probe when lsof output is empty", async () => { + let probedPort: number | null = null; + const result = await checkPortAvailable(18789, { + lsofOutput: "", + probeImpl: async (port) => { + probedPort = port; + return { ok: true }; + }, + }); + + expect(probedPort).toBe(18789); + expect(result).toEqual({ ok: true }); + }); + + it("probe catches occupied port even when lsof returns empty", async () => { + const result = await checkPortAvailable(18789, { + lsofOutput: "", + probeImpl: async () => ({ + ok: false, + process: "unknown", + pid: null, + reason: "port 18789 is in use (EADDRINUSE)", + }), + }); + + expect(result.ok).toBe(false); + expect(result.process).toBe("unknown"); + expect(result.reason).toContain("EADDRINUSE"); + }); + + it("parses process and PID from lsof output", async () => { + const lsofOutput = [ + "COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME", + "openclaw 12345 root 7u IPv4 54321 0t0 TCP *:18789 (LISTEN)", + ].join("\n"); + const result = await checkPortAvailable(18789, { lsofOutput }); + + expect(result.ok).toBe(false); + expect(result.process).toBe("openclaw"); + expect(result.pid).toBe(12345); + expect(result.reason).toContain("openclaw"); + }); + + it("picks first listener when lsof shows multiple", async () => { + const lsofOutput = [ + "COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME", + "gateway 111 root 7u IPv4 54321 0t0 TCP *:18789 (LISTEN)", + "node 222 root 8u IPv4 54322 0t0 TCP *:18789 (LISTEN)", + ].join("\n"); + const result = await checkPortAvailable(18789, { lsofOutput }); + + expect(result.ok).toBe(false); + expect(result.process).toBe("gateway"); + expect(result.pid).toBe(111); + }); + + it("returns ok for a free port probe", async () => { + const result = await checkPortAvailable(8080, { + skipLsof: true, + probeImpl: async () => ({ ok: true }), + }); + + expect(result).toEqual({ ok: true }); + }); + + it("returns occupied for EADDRINUSE probe results", async () => { + const result = await checkPortAvailable(8080, { + skipLsof: true, + probeImpl: async () => ({ + ok: false, + process: "unknown", + pid: null, + reason: "port 8080 is in use (EADDRINUSE)", + }), + }); + + expect(result.ok).toBe(false); + expect(result.process).toBe("unknown"); + expect(result.reason).toContain("EADDRINUSE"); + }); + + it("treats restricted probe environments as inconclusive instead of occupied", async () => { + const result = await checkPortAvailable(8080, { + skipLsof: true, + probeImpl: async () => ({ + ok: true as const, + warning: "port probe skipped: listen EPERM: operation not permitted 127.0.0.1", + }), + }); + + expect(result.ok).toBe(true); + expect(result.warning).toContain("EPERM"); + }); + + it("defaults to port 18789 when no port is given", async () => { + let probedPort: number | null = null; + const result = await checkPortAvailable(undefined, { + skipLsof: true, + probeImpl: async (port) => { + probedPort = port; + return { ok: true }; + }, + }); + + expect(probedPort).toBe(18789); + expect(result.ok).toBe(true); + }); +}); + +describe("probePortAvailability", () => { + // Import probePortAvailability directly for targeted testing + const { probePortAvailability } = require("../../dist/lib/preflight"); + + it("returns ok when port is free (real net probe)", async () => { + // Use a high ephemeral port unlikely to be in use + const result = await probePortAvailability(0, {}); + // Port 0 lets the OS pick a free port, so it should always succeed + expect(result.ok).toBe(true); + }); + + it("detects EADDRINUSE on an occupied port (real net probe)", async () => { + // Start a server on a random port, then probe it + const net = require("node:net"); + const srv = net.createServer(); + await new Promise((resolve) => srv.listen(0, "127.0.0.1", resolve)); + const port = srv.address().port; + try { + const result = await probePortAvailability(port, {}); + expect(result.ok).toBe(false); + expect(result.reason).toContain("EADDRINUSE"); + } finally { + await new Promise((resolve) => srv.close(resolve)); + } + }); + + it("delegates to probeImpl when provided", async () => { + let called = false; + const result = await probePortAvailability(9999, { + probeImpl: async (port: number) => { + called = true; + expect(port).toBe(9999); + return { ok: true as const }; + }, + }); + expect(called).toBe(true); + expect(result.ok).toBe(true); + }); +}); + +describe("checkPortAvailable — real probe fallback", () => { + it("returns ok for a free port via full detection chain", async () => { + // skipLsof forces the net probe path; use port 0 which is always free + const result = await checkPortAvailable(0, { skipLsof: true }); + expect(result.ok).toBe(true); + }); + + it("detects a real occupied port", async () => { + const net = require("node:net"); + const srv = net.createServer(); + await new Promise((resolve) => srv.listen(0, "127.0.0.1", resolve)); + const port = srv.address().port; + try { + const result = await checkPortAvailable(port, { skipLsof: true }); + expect(result.ok).toBe(false); + } finally { + await new Promise((resolve) => srv.close(resolve)); + } + }); +}); + +describe("checkPortAvailable — sudo -n lsof retry", () => { + it("uses sudo -n (non-interactive) for the lsof retry path", async () => { + // When lsof returns empty (non-root can't see root-owned listeners), + // checkPortAvailable retries with sudo -n. We can't easily test this + // without mocking runCapture, but we can verify the lsofOutput injection + // path handles header-only output correctly (falls through to probe). + let probed = false; + const result = await checkPortAvailable(18789, { + lsofOutput: "COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME\n", + probeImpl: async () => { + probed = true; + return { ok: true }; + }, + }); + expect(probed).toBe(true); + expect(result.ok).toBe(true); + }); +}); + +describe("getMemoryInfo", () => { + it("parses valid /proc/meminfo content", () => { + const meminfoContent = [ + "MemTotal: 8152056 kB", + "MemFree: 1234567 kB", + "MemAvailable: 4567890 kB", + "SwapTotal: 4194300 kB", + "SwapFree: 4194300 kB", + ].join("\n"); + + const result = getMemoryInfo({ meminfoContent, platform: "linux" }); + expect(result).not.toBeNull(); + expect(result!.totalRamMB).toBe(Math.floor(8152056 / 1024)); + expect(result!.totalSwapMB).toBe(Math.floor(4194300 / 1024)); + expect(result!.totalMB).toBe(result!.totalRamMB + result!.totalSwapMB); + }); + + it("returns correct values when swap is zero", () => { + const meminfoContent = [ + "MemTotal: 8152056 kB", + "MemFree: 1234567 kB", + "SwapTotal: 0 kB", + "SwapFree: 0 kB", + ].join("\n"); + + const result = getMemoryInfo({ meminfoContent, platform: "linux" }); + expect(result).not.toBeNull(); + expect(result!.totalRamMB).toBe(Math.floor(8152056 / 1024)); + expect(result!.totalSwapMB).toBe(0); + expect(result!.totalMB).toBe(result!.totalRamMB); + }); + + it("returns null on unsupported platforms", () => { + const result = getMemoryInfo({ platform: "win32" }); + expect(result).toBeNull(); + }); + + it("returns null on darwin when sysctl returns empty", () => { + // When runCapture("sysctl -n hw.memsize") returns empty/falsy, + // getMemoryInfo should return null rather than crash. + // This exercises the darwin branch without requiring a real sysctl binary. + const result = getMemoryInfo({ platform: "darwin" }); + // On macOS with sysctl available, returns info; otherwise null — both are valid + if (result !== null) { + expect(result.totalRamMB).toBeGreaterThan(0); + expect(result.totalSwapMB).toBe(0); + } + }); + + it("handles malformed /proc/meminfo gracefully", () => { + const result = getMemoryInfo({ + meminfoContent: "garbage data\nno fields here", + platform: "linux", + }); + expect(result).not.toBeNull(); + expect(result!.totalRamMB).toBe(0); + expect(result!.totalSwapMB).toBe(0); + expect(result!.totalMB).toBe(0); + }); +}); + +describe("ensureSwap", () => { + it("returns ok when total memory already exceeds threshold", () => { + const result = ensureSwap(6144, { + platform: "linux", + memoryInfo: { totalRamMB: 8000, totalSwapMB: 0, totalMB: 8000 }, + }); + expect(result.ok).toBe(true); + expect(result.swapCreated).toBe(false); + expect(result.totalMB).toBe(8000); + }); + + it("reports swap would be created in dry-run mode when below threshold", () => { + const result = ensureSwap(6144, { + platform: "linux", + memoryInfo: { totalRamMB: 4000, totalSwapMB: 0, totalMB: 4000 }, + dryRun: true, + swapfileExists: false, + }); + expect(result.ok).toBe(true); + expect(result.swapCreated).toBe(true); + }); + + it("skips swap creation when /swapfile already exists (dry-run)", () => { + const result = ensureSwap(6144, { + platform: "linux", + memoryInfo: { totalRamMB: 4000, totalSwapMB: 0, totalMB: 4000 }, + dryRun: true, + swapfileExists: true, + }); + expect(result.ok).toBe(true); + expect(result.swapCreated).toBe(false); + expect(result.reason).toMatch(/swapfile already exists/); + }); + + it("skips on non-Linux platforms", () => { + const result = ensureSwap(6144, { + platform: "darwin", + memoryInfo: { totalRamMB: 4000, totalSwapMB: 0, totalMB: 4000 }, + }); + expect(result.ok).toBe(true); + expect(result.swapCreated).toBe(false); + }); + + it("returns error when memory info is unavailable", () => { + const result = ensureSwap(6144, { + platform: "linux", + memoryInfo: null, + getMemoryInfoImpl: () => null, + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/could not read memory info/); + }); + + it("uses default 12000 MB threshold when minTotalMB is undefined", () => { + const result = ensureSwap(undefined, { + platform: "linux", + memoryInfo: { totalRamMB: 16000, totalSwapMB: 0, totalMB: 16000 }, + }); + expect(result.ok).toBe(true); + expect(result.swapCreated).toBe(false); + expect(result.totalMB).toBe(16000); + }); + + it("uses getMemoryInfoImpl when memoryInfo is not provided", () => { + let called = false; + const result = ensureSwap(6144, { + platform: "linux", + getMemoryInfoImpl: () => { + called = true; + return { totalRamMB: 8000, totalSwapMB: 0, totalMB: 8000 }; + }, + }); + expect(called).toBe(true); + expect(result.ok).toBe(true); + }); +}); diff --git a/src/lib/preflight.ts b/src/lib/preflight.ts new file mode 100644 index 000000000..8f874e3b6 --- /dev/null +++ b/src/lib/preflight.ts @@ -0,0 +1,407 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Preflight checks for NemoClaw onboarding: port availability, memory + * info, and swap management. + * + * Every function accepts an opts object for dependency injection so + * tests can run without real I/O. + */ + +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; + +// runner.js is CJS — use require so we don't pull it into the TS build. +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { runCapture } = require("../../bin/lib/runner"); + +// ── Types ──────────────────────────────────────────────────────── + +export interface PortProbeResult { + ok: boolean; + warning?: string; + process?: string; + pid?: number | null; + reason?: string; +} + +export interface CheckPortOpts { + /** Inject fake lsof output (skips shell). */ + lsofOutput?: string; + /** Force the net-probe fallback path. */ + skipLsof?: boolean; + /** Async probe implementation for testing. */ + probeImpl?: (port: number) => Promise; +} + +export interface MemoryInfo { + totalRamMB: number; + totalSwapMB: number; + totalMB: number; +} + +export interface GetMemoryInfoOpts { + /** Inject fake /proc/meminfo content. */ + meminfoContent?: string; + /** Override process.platform. */ + platform?: NodeJS.Platform; +} + +export interface SwapResult { + ok: boolean; + totalMB?: number; + swapCreated?: boolean; + reason?: string; +} + +export interface EnsureSwapOpts { + /** Override process.platform. */ + platform?: NodeJS.Platform; + /** Inject mock getMemoryInfo() result. */ + memoryInfo?: MemoryInfo | null; + /** Whether /swapfile exists (override for testing). */ + swapfileExists?: boolean; + /** Skip actual swap creation. */ + dryRun?: boolean; + /** Whether the session is interactive. */ + interactive?: boolean; + /** Override getMemoryInfo implementation. */ + getMemoryInfoImpl?: (opts: GetMemoryInfoOpts) => MemoryInfo | null; +} + +// ── Port availability ──────────────────────────────────────────── + +export async function probePortAvailability( + port: number, + opts: Pick = {}, +): Promise { + if (typeof opts.probeImpl === "function") { + return opts.probeImpl(port); + } + + return new Promise((resolve) => { + const srv = net.createServer(); + srv.once("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EADDRINUSE") { + resolve({ + ok: false, + process: "unknown", + pid: null, + reason: `port ${port} is in use (EADDRINUSE)`, + }); + return; + } + + if (err.code === "EPERM" || err.code === "EACCES") { + resolve({ + ok: true, + warning: `port probe skipped: ${err.message}`, + }); + return; + } + + // Unexpected probe failure: do not report a false conflict. + resolve({ + ok: true, + warning: `port probe inconclusive: ${err.message}`, + }); + }); + srv.listen(port, "127.0.0.1", () => { + srv.close(() => resolve({ ok: true })); + }); + }); +} + +function parseLsofLines(output: string): PortProbeResult | null { + const lines = output.split("\n").filter((l) => l.trim()); + const dataLines = lines.filter((l) => !l.startsWith("COMMAND")); + if (dataLines.length === 0) return null; + + const parts = dataLines[0].split(/\s+/); + const proc = parts[0] || "unknown"; + const pid = parseInt(parts[1], 10) || null; + return { ok: false, process: proc, pid, reason: "" }; +} + +/** + * Check whether a TCP port is available for listening. + * + * Detection chain: + * 1. lsof (primary) — identifies the blocking process name + PID + * 2. Node.js net probe (fallback) — cross-platform, detects EADDRINUSE + */ +export async function checkPortAvailable( + port?: number, + opts?: CheckPortOpts, +): Promise { + const p = port ?? 18789; + const o = opts || {}; + + // ── lsof path ────────────────────────────────────────────────── + if (!o.skipLsof) { + let lsofOut: string | undefined; + if (typeof o.lsofOutput === "string") { + lsofOut = o.lsofOutput; + } else { + const hasLsof = runCapture("command -v lsof", { ignoreError: true }); + if (hasLsof) { + lsofOut = runCapture(`lsof -i :${p} -sTCP:LISTEN -P -n 2>/dev/null`, { + ignoreError: true, + }); + } + } + + if (typeof lsofOut === "string") { + const conflict = parseLsofLines(lsofOut); + if (conflict) { + return { + ...conflict, + reason: `lsof reports ${conflict.process} (PID ${conflict.pid}) listening on port ${p}`, + }; + } + + // Empty lsof output is not authoritative — non-root users cannot + // see listeners owned by root (e.g., docker-proxy, leftover gateway). + // Retry with sudo -n to identify root-owned listeners before falling + // through to the net probe (which can only detect EADDRINUSE but not + // the owning process). + if (!o.lsofOutput) { + const sudoOut: string | undefined = runCapture( + `sudo -n lsof -i :${p} -sTCP:LISTEN -P -n 2>/dev/null`, + { ignoreError: true }, + ); + if (typeof sudoOut === "string") { + const sudoConflict = parseLsofLines(sudoOut); + if (sudoConflict) { + return { + ...sudoConflict, + reason: `sudo lsof reports ${sudoConflict.process} (PID ${sudoConflict.pid}) listening on port ${p}`, + }; + } + } + } + } + } + + // ── net probe fallback ───────────────────────────────────────── + return probePortAvailability(p, o); +} + +// ── Memory info ────────────────────────────────────────────────── + +export function getMemoryInfo(opts?: GetMemoryInfoOpts): MemoryInfo | null { + const o = opts || {}; + const platform = o.platform || process.platform; + + if (platform === "linux") { + let content: string; + if (typeof o.meminfoContent === "string") { + content = o.meminfoContent; + } else { + try { + content = fs.readFileSync("/proc/meminfo", "utf-8"); + } catch { + return null; + } + } + + const parseKB = (key: string): number => { + const match = content.match(new RegExp(`^${key}:\\s+(\\d+)`, "m")); + return match ? parseInt(match[1], 10) : 0; + }; + + const totalRamKB = parseKB("MemTotal"); + const totalSwapKB = parseKB("SwapTotal"); + const totalRamMB = Math.floor(totalRamKB / 1024); + const totalSwapMB = Math.floor(totalSwapKB / 1024); + return { totalRamMB, totalSwapMB, totalMB: totalRamMB + totalSwapMB }; + } + + if (platform === "darwin") { + try { + const memBytes = parseInt(runCapture("sysctl -n hw.memsize", { ignoreError: true }), 10); + if (!memBytes || isNaN(memBytes)) return null; + const totalRamMB = Math.floor(memBytes / 1024 / 1024); + // macOS does not use traditional swap files in the same way + return { totalRamMB, totalSwapMB: 0, totalMB: totalRamMB }; + } catch { + return null; + } + } + + return null; +} + +// ── Swap management (Linux only) ───────────────────────────────── + +function hasSwapfile(): boolean { + try { + fs.accessSync("/swapfile"); + return true; + } catch { + return false; + } +} + +function getExistingSwapResult(mem: MemoryInfo): SwapResult | null { + if (!hasSwapfile()) { + return null; + } + + const swaps = (() => { + try { + return fs.readFileSync("/proc/swaps", "utf-8"); + } catch { + return ""; + } + })(); + + if (swaps.includes("/swapfile")) { + return { + ok: true, + totalMB: mem.totalMB, + swapCreated: false, + reason: "/swapfile already exists", + }; + } + + try { + runCapture("sudo swapon /swapfile", { ignoreError: false }); + return { ok: true, totalMB: mem.totalMB + 4096, swapCreated: true }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + return { + ok: false, + reason: `found orphaned /swapfile but could not activate it: ${message}`, + }; + } +} + +function checkSwapDiskSpace(): SwapResult | null { + try { + const dfOut = runCapture("df / --output=avail -k 2>/dev/null | tail -1", { + ignoreError: true, + }); + const freeKB = parseInt((dfOut || "").trim(), 10); + if (!isNaN(freeKB) && freeKB < 5000000) { + return { + ok: false, + reason: `insufficient disk space (${Math.floor(freeKB / 1024)} MB free, need ~5 GB) to create swap file`, + }; + } + } catch { + // df unavailable — let dd fail naturally if out of space + } + + return null; +} + +function writeManagedSwapMarker(): void { + const nemoclawDir = path.join(os.homedir(), ".nemoclaw"); + if (!fs.existsSync(nemoclawDir)) { + runCapture(`mkdir -p ${nemoclawDir}`, { ignoreError: true }); + } + + try { + fs.writeFileSync(path.join(nemoclawDir, "managed_swap"), "/swapfile"); + } catch { + // Best effort marker write. + } +} + +function cleanupPartialSwap(): void { + try { + runCapture("sudo swapoff /swapfile 2>/dev/null || true", { ignoreError: true }); + runCapture("sudo rm -f /swapfile", { ignoreError: true }); + } catch { + // Best effort cleanup + } +} + +function createSwapfile(mem: MemoryInfo): SwapResult { + try { + runCapture("sudo dd if=/dev/zero of=/swapfile bs=1M count=4096 status=none", { + ignoreError: false, + }); + runCapture("sudo chmod 600 /swapfile", { ignoreError: false }); + runCapture("sudo mkswap /swapfile", { ignoreError: false }); + runCapture("sudo swapon /swapfile", { ignoreError: false }); + runCapture( + "grep -q '/swapfile' /etc/fstab || echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab", + { ignoreError: false }, + ); + writeManagedSwapMarker(); + + return { ok: true, totalMB: mem.totalMB + 4096, swapCreated: true }; + } catch (err: unknown) { + cleanupPartialSwap(); + const message = err instanceof Error ? err.message : String(err); + return { + ok: false, + reason: + `swap creation failed: ${message}. Create swap manually:\n` + + " sudo dd if=/dev/zero of=/swapfile bs=1M count=4096 status=none && sudo chmod 600 /swapfile && " + + "sudo mkswap /swapfile && sudo swapon /swapfile", + }; + } +} + +/** + * Ensure the system has enough memory (RAM + swap) for sandbox operations. + * + * If total memory is below minTotalMB and no swap file exists, attempts to + * create a 4 GB swap file via sudo to prevent OOM kills during sandbox + * image push. + */ +export function ensureSwap(minTotalMB?: number, opts: EnsureSwapOpts = {}): SwapResult { + const o = { + platform: process.platform as NodeJS.Platform, + memoryInfo: null as MemoryInfo | null, + swapfileExists: fs.existsSync("/swapfile"), + dryRun: false, + interactive: process.stdout.isTTY && !process.env.NEMOCLAW_NON_INTERACTIVE, + getMemoryInfoImpl: getMemoryInfo, + ...opts, + }; + const threshold = minTotalMB ?? 12000; + + if (o.platform !== "linux") { + return { ok: true, totalMB: 0, swapCreated: false }; + } + + const mem = o.memoryInfo ?? o.getMemoryInfoImpl({ platform: o.platform }); + if (!mem) { + return { ok: false, reason: "could not read memory info" }; + } + + if (mem.totalMB >= threshold) { + return { ok: true, totalMB: mem.totalMB, swapCreated: false }; + } + + if (o.dryRun) { + if (o.swapfileExists) { + return { + ok: true, + totalMB: mem.totalMB, + swapCreated: false, + reason: "/swapfile already exists", + }; + } + return { ok: true, totalMB: mem.totalMB, swapCreated: true }; + } + + const existingSwapResult = getExistingSwapResult(mem); + if (existingSwapResult) { + return existingSwapResult; + } + + const diskSpaceResult = checkSwapDiskSpace(); + if (diskSpaceResult) { + return diskSpaceResult; + } + + return createSwapfile(mem); +} diff --git a/src/lib/resolve-openshell.test.ts b/src/lib/resolve-openshell.test.ts new file mode 100644 index 000000000..15b90cb8f --- /dev/null +++ b/src/lib/resolve-openshell.test.ts @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect } from "vitest"; +import { resolveOpenshell } from "../../dist/lib/resolve-openshell"; + +describe("lib/resolve-openshell", () => { + it("returns command -v result when absolute path", () => { + expect(resolveOpenshell({ commandVResult: "/usr/bin/openshell" })).toBe("/usr/bin/openshell"); + }); + + it("rejects non-absolute command -v result (alias)", () => { + expect( + resolveOpenshell({ commandVResult: "openshell", checkExecutable: () => false }), + ).toBeNull(); + }); + + it("rejects alias definition from command -v", () => { + expect( + resolveOpenshell({ + commandVResult: "alias openshell='echo pwned'", + checkExecutable: () => false, + }), + ).toBeNull(); + }); + + it("falls back to ~/.local/bin when command -v fails", () => { + expect( + resolveOpenshell({ + commandVResult: null, + checkExecutable: (p) => p === "/fakehome/.local/bin/openshell", + home: "/fakehome", + }), + ).toBe("/fakehome/.local/bin/openshell"); + }); + + it("falls back to /usr/local/bin", () => { + expect( + resolveOpenshell({ + commandVResult: null, + checkExecutable: (p) => p === "/usr/local/bin/openshell", + }), + ).toBe("/usr/local/bin/openshell"); + }); + + it("falls back to /usr/bin", () => { + expect( + resolveOpenshell({ + commandVResult: null, + checkExecutable: (p) => p === "/usr/bin/openshell", + }), + ).toBe("/usr/bin/openshell"); + }); + + it("prefers ~/.local/bin over /usr/local/bin", () => { + expect( + resolveOpenshell({ + commandVResult: null, + checkExecutable: (p) => + p === "/fakehome/.local/bin/openshell" || p === "/usr/local/bin/openshell", + home: "/fakehome", + }), + ).toBe("/fakehome/.local/bin/openshell"); + }); + + it("returns null when openshell not found anywhere", () => { + expect( + resolveOpenshell({ + commandVResult: null, + checkExecutable: () => false, + }), + ).toBeNull(); + }); + + it("skips home candidate when home is not absolute", () => { + expect( + resolveOpenshell({ + commandVResult: null, + checkExecutable: () => false, + home: "relative/path", + }), + ).toBeNull(); + }); + +}); diff --git a/src/lib/resolve-openshell.ts b/src/lib/resolve-openshell.ts new file mode 100644 index 000000000..b55fbfac8 --- /dev/null +++ b/src/lib/resolve-openshell.ts @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { execSync } from "node:child_process"; +import { accessSync, constants } from "node:fs"; + +export interface ResolveOpenshellOptions { + /** Mock result for `command -v` (undefined = run real command). */ + commandVResult?: string | null; + /** Override executable check (default: fs.accessSync X_OK). */ + checkExecutable?: (path: string) => boolean; + /** HOME directory override. */ + home?: string; +} + +/** + * Resolve the openshell binary path. + * + * Checks `command -v` first (must return an absolute path to prevent alias + * injection), then falls back to common installation directories. + */ +export function resolveOpenshell(opts: ResolveOpenshellOptions = {}): string | null { + const home = opts.home ?? process.env.HOME; + + // Step 1: command -v + if (opts.commandVResult === undefined) { + try { + const found = execSync("command -v openshell", { encoding: "utf-8" }).trim(); + if (found.startsWith("/")) return found; + } catch { + /* ignored */ + } + } else if (opts.commandVResult?.startsWith("/")) { + return opts.commandVResult; + } + + // Step 2: fallback candidates + const checkExecutable = + opts.checkExecutable ?? + ((p: string): boolean => { + try { + accessSync(p, constants.X_OK); + return true; + } catch { + return false; + } + }); + + const candidates = [ + ...(home?.startsWith("/") ? [`${home}/.local/bin/openshell`] : []), + "/usr/local/bin/openshell", + "/usr/bin/openshell", + ]; + for (const p of candidates) { + if (checkExecutable(p)) return p; + } + + return null; +} diff --git a/src/lib/runtime-recovery.test.ts b/src/lib/runtime-recovery.test.ts new file mode 100644 index 000000000..868934f3f --- /dev/null +++ b/src/lib/runtime-recovery.test.ts @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +// Import from compiled dist/ for correct coverage attribution. +import { + classifyGatewayStatus, + classifySandboxLookup, + parseLiveSandboxNames, + shouldAttemptGatewayRecovery, +} from "../../dist/lib/runtime-recovery"; + +describe("runtime recovery helpers", () => { + it("parses live sandbox names from openshell sandbox list output", () => { + expect( + Array.from( + parseLiveSandboxNames( + [ + "NAME NAMESPACE CREATED PHASE", + "alpha openshell 2026-03-24 10:00:00 Ready", + "beta openshell 2026-03-24 10:01:00 Provisioning", + ].join("\n"), + ), + ), + ).toEqual(["alpha", "beta"]); + }); + + it("treats no-sandboxes output as an empty set", () => { + expect(Array.from(parseLiveSandboxNames("No sandboxes found."))).toEqual([]); + }); + + it("skips error lines", () => { + expect(Array.from(parseLiveSandboxNames("Error: something went wrong"))).toEqual([]); + }); + + it("handles empty input", () => { + expect(Array.from(parseLiveSandboxNames(""))).toEqual([]); + expect(Array.from(parseLiveSandboxNames())).toEqual([]); + }); + + it("classifies missing sandbox lookups", () => { + expect( + classifySandboxLookup('Error: × status: NotFound, message: "sandbox not found"').state, + ).toBe("missing"); + expect(classifySandboxLookup("").state).toBe("missing"); + }); + + it("classifies transport and gateway failures as unavailable", () => { + expect( + classifySandboxLookup( + "Error: × transport error\n ╰─▶ Connection reset by peer (os error 104)", + ).state, + ).toBe("unavailable"); + expect( + classifySandboxLookup( + "Error: × client error (Connect)\n ╰─▶ Connection refused (os error 111)", + ).state, + ).toBe("unavailable"); + }); + + it("classifies successful sandbox lookups as present", () => { + expect( + classifySandboxLookup( + ["Sandbox:", "", " Id: abc", " Name: my-assistant", " Phase: Ready"].join("\n"), + ).state, + ).toBe("present"); + }); + + it("classifies gateway status output for restart recovery", () => { + expect(classifyGatewayStatus("Gateway: nemoclaw\nStatus: Connected").state).toBe("connected"); + expect(classifyGatewayStatus("Error: × No active gateway").state).toBe("unavailable"); + expect(classifyGatewayStatus("").state).toBe("inactive"); + expect(classifyGatewayStatus("Gateway: nemoclaw\nStatus: Disconnected").state).toBe( + "inactive", + ); + expect(classifyGatewayStatus("Status: Not connected").state).toBe("inactive"); + expect(classifyGatewayStatus("Connected").state).toBe("connected"); + }); + + it("only attempts gateway recovery when sandbox access is unavailable and gateway is down", () => { + expect( + shouldAttemptGatewayRecovery({ sandboxState: "unavailable", gatewayState: "unavailable" }), + ).toBe(true); + expect( + shouldAttemptGatewayRecovery({ sandboxState: "unavailable", gatewayState: "inactive" }), + ).toBe(true); + expect( + shouldAttemptGatewayRecovery({ sandboxState: "present", gatewayState: "unavailable" }), + ).toBe(false); + expect( + shouldAttemptGatewayRecovery({ sandboxState: "missing", gatewayState: "inactive" }), + ).toBe(false); + expect( + shouldAttemptGatewayRecovery({ sandboxState: "unavailable", gatewayState: "connected" }), + ).toBe(false); + }); +}); diff --git a/src/lib/runtime-recovery.ts b/src/lib/runtime-recovery.ts new file mode 100644 index 000000000..d33162f59 --- /dev/null +++ b/src/lib/runtime-recovery.ts @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Runtime recovery helpers — classify sandbox/gateway state from CLI + * output and determine recovery strategy. + */ + +// onboard-session is CJS — keep as require +// eslint-disable-next-line @typescript-eslint/no-require-imports +const onboardSession = require("../../bin/lib/onboard-session"); + +// eslint-disable-next-line no-control-regex +const ANSI_RE = /\x1b\[[0-9;]*m/g; + +function stripAnsi(text: unknown): string { + return String(text || "").replace(ANSI_RE, ""); +} + +export interface StateClassification { + state: string; + reason: string; +} + +export function parseLiveSandboxNames(listOutput = ""): Set { + const clean = stripAnsi(listOutput); + const names = new Set(); + for (const rawLine of clean.split("\n")) { + const line = rawLine.trim(); + if (!line) continue; + if (/^(NAME|No sandboxes found\.?$)/i.test(line)) continue; + if (/^Error:/i.test(line)) continue; + const cols = line.split(/\s+/); + if (cols[0]) { + names.add(cols[0]); + } + } + return names; +} + +export function classifySandboxLookup(output = ""): StateClassification { + const clean = stripAnsi(output).trim(); + if (!clean) { + return { state: "missing", reason: "empty" }; + } + if (/sandbox not found|status:\s*NotFound/i.test(clean)) { + return { state: "missing", reason: "not_found" }; + } + if ( + /transport error|client error|Connection reset by peer|Connection refused|No active gateway|Gateway: .*Error/i.test( + clean, + ) + ) { + return { state: "unavailable", reason: "gateway_unavailable" }; + } + return { state: "present", reason: "ok" }; +} + +export function classifyGatewayStatus(output = ""): StateClassification { + const clean = stripAnsi(output).trim(); + if (!clean) { + return { state: "inactive", reason: "empty" }; + } + if ( + /No active gateway|transport error|client error|Connection reset by peer|Connection refused|Gateway: .*Error/i.test( + clean, + ) + ) { + return { state: "unavailable", reason: "gateway_unavailable" }; + } + if (/^\s*(?:Status:\s*)?Connected\s*$/im.test(clean)) { + return { state: "connected", reason: "ok" }; + } + return { state: "inactive", reason: "not_connected" }; +} + +export function shouldAttemptGatewayRecovery({ + sandboxState = "missing", + gatewayState = "inactive", +} = {}): boolean { + return sandboxState === "unavailable" && gatewayState !== "connected"; +} + +export function getRecoveryCommand(): string { + const session = onboardSession.loadSession(); + if (session && session.resumable !== false) { + return "nemoclaw onboard --resume"; + } + return "nemoclaw onboard"; +} diff --git a/src/lib/services.test.ts b/src/lib/services.test.ts new file mode 100644 index 000000000..702438e48 --- /dev/null +++ b/src/lib/services.test.ts @@ -0,0 +1,162 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +// Import from compiled dist/ so coverage is attributed correctly. +import { + getServiceStatuses, + showStatus, + stopAll, +} from "../../dist/lib/services"; + +describe("getServiceStatuses", () => { + let pidDir: string; + + beforeEach(() => { + pidDir = mkdtempSync(join(tmpdir(), "nemoclaw-svc-test-")); + }); + + afterEach(() => { + rmSync(pidDir, { recursive: true, force: true }); + }); + + it("returns stopped status when no PID files exist", () => { + const statuses = getServiceStatuses({ pidDir }); + expect(statuses).toHaveLength(2); + for (const s of statuses) { + expect(s.running).toBe(false); + expect(s.pid).toBeNull(); + } + }); + + it("returns service names telegram-bridge and cloudflared", () => { + const statuses = getServiceStatuses({ pidDir }); + const names = statuses.map((s) => s.name); + expect(names).toContain("telegram-bridge"); + expect(names).toContain("cloudflared"); + }); + + it("detects a stale PID file as not running with null pid", () => { + // Write a PID that doesn't correspond to a running process + writeFileSync(join(pidDir, "cloudflared.pid"), "999999999"); + const statuses = getServiceStatuses({ pidDir }); + const cf = statuses.find((s) => s.name === "cloudflared"); + expect(cf?.running).toBe(false); + // Dead processes should have pid normalized to null + expect(cf?.pid).toBeNull(); + }); + + it("ignores invalid PID file contents", () => { + writeFileSync(join(pidDir, "telegram-bridge.pid"), "not-a-number"); + const statuses = getServiceStatuses({ pidDir }); + const tg = statuses.find((s) => s.name === "telegram-bridge"); + expect(tg?.pid).toBeNull(); + expect(tg?.running).toBe(false); + }); + + it("creates pidDir if it does not exist", () => { + const nested = join(pidDir, "nested", "deep"); + const statuses = getServiceStatuses({ pidDir: nested }); + expect(existsSync(nested)).toBe(true); + expect(statuses).toHaveLength(2); + }); +}); + +describe("sandbox name validation", () => { + it("rejects names with path traversal", () => { + expect(() => getServiceStatuses({ sandboxName: "../escape" })).toThrow("Invalid sandbox name"); + }); + + it("rejects names with slashes", () => { + expect(() => getServiceStatuses({ sandboxName: "foo/bar" })).toThrow("Invalid sandbox name"); + }); + + it("rejects empty names", () => { + expect(() => getServiceStatuses({ sandboxName: "" })).toThrow("Invalid sandbox name"); + }); + + it("accepts valid alphanumeric names", () => { + expect(() => getServiceStatuses({ sandboxName: "my-sandbox.1" })).not.toThrow(); + }); +}); + +describe("showStatus", () => { + let pidDir: string; + + beforeEach(() => { + pidDir = mkdtempSync(join(tmpdir(), "nemoclaw-svc-test-")); + }); + + afterEach(() => { + rmSync(pidDir, { recursive: true, force: true }); + }); + + it("prints stopped status for all services", () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + showStatus({ pidDir }); + const output = logSpy.mock.calls.map((c) => c[0]).join("\n"); + expect(output).toContain("telegram-bridge"); + expect(output).toContain("cloudflared"); + expect(output).toContain("stopped"); + logSpy.mockRestore(); + }); + + it("does not show tunnel URL when cloudflared is not running", () => { + // Write a stale log file but no running process + writeFileSync( + join(pidDir, "cloudflared.log"), + "https://abc-def.trycloudflare.com", + ); + writeFileSync(join(pidDir, "cloudflared.pid"), "999999999"); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + showStatus({ pidDir }); + const output = logSpy.mock.calls.map((c) => c[0]).join("\n"); + // Should NOT show the URL since cloudflared is not actually running + expect(output).not.toContain("Public URL"); + logSpy.mockRestore(); + }); +}); + +describe("stopAll", () => { + let pidDir: string; + + beforeEach(() => { + pidDir = mkdtempSync(join(tmpdir(), "nemoclaw-svc-test-")); + }); + + afterEach(() => { + rmSync(pidDir, { recursive: true, force: true }); + }); + + it("removes stale PID files", () => { + writeFileSync(join(pidDir, "cloudflared.pid"), "999999999"); + writeFileSync(join(pidDir, "telegram-bridge.pid"), "999999998"); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + stopAll({ pidDir }); + logSpy.mockRestore(); + + expect(existsSync(join(pidDir, "cloudflared.pid"))).toBe(false); + expect(existsSync(join(pidDir, "telegram-bridge.pid"))).toBe(false); + }); + + it("is idempotent — calling twice does not throw", () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + stopAll({ pidDir }); + stopAll({ pidDir }); + logSpy.mockRestore(); + }); + + it("logs stop messages", () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + stopAll({ pidDir }); + const output = logSpy.mock.calls.map((c) => c[0]).join("\n"); + expect(output).toContain("All services stopped"); + logSpy.mockRestore(); + }); +}); diff --git a/src/lib/services.ts b/src/lib/services.ts new file mode 100644 index 000000000..9582a5921 --- /dev/null +++ b/src/lib/services.ts @@ -0,0 +1,383 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { execFileSync, execSync, spawn } from "node:child_process"; +import { + closeSync, + existsSync, + mkdirSync, + openSync, + readFileSync, + writeFileSync, + unlinkSync, +} from "node:fs"; +import { join } from "node:path"; +import { platform } from "node:os"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface ServiceOptions { + /** Sandbox name — must match the name used by start/stop/status. */ + sandboxName?: string; + /** Dashboard port for cloudflared (default: 18789). */ + dashboardPort?: number; + /** Repo root directory — used to locate scripts/. */ + repoDir?: string; + /** Override PID directory (default: /tmp/nemoclaw-services-{sandbox}). */ + pidDir?: string; +} + +export interface ServiceStatus { + name: string; + running: boolean; + pid: number | null; +} + +// --------------------------------------------------------------------------- +// Colour helpers — respect NO_COLOR +// --------------------------------------------------------------------------- + +const useColor = !process.env.NO_COLOR && process.stdout.isTTY; +const GREEN = useColor ? "\x1b[0;32m" : ""; +const RED = useColor ? "\x1b[0;31m" : ""; +const YELLOW = useColor ? "\x1b[1;33m" : ""; +const NC = useColor ? "\x1b[0m" : ""; + +function info(msg: string): void { + console.log(`${GREEN}[services]${NC} ${msg}`); +} + +function warn(msg: string): void { + console.log(`${YELLOW}[services]${NC} ${msg}`); +} + +// --------------------------------------------------------------------------- +// PID helpers +// --------------------------------------------------------------------------- + +function ensurePidDir(pidDir: string): void { + if (!existsSync(pidDir)) { + mkdirSync(pidDir, { recursive: true }); + } +} + +function readPid(pidDir: string, name: string): number | null { + const pidFile = join(pidDir, `${name}.pid`); + if (!existsSync(pidFile)) return null; + const raw = readFileSync(pidFile, "utf-8").trim(); + const pid = Number(raw); + return Number.isFinite(pid) && pid > 0 ? pid : null; +} + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function isRunning(pidDir: string, name: string): boolean { + const pid = readPid(pidDir, name); + if (pid === null) return false; + return isAlive(pid); +} + +function writePid(pidDir: string, name: string, pid: number): void { + writeFileSync(join(pidDir, `${name}.pid`), String(pid)); +} + +function removePid(pidDir: string, name: string): void { + const pidFile = join(pidDir, `${name}.pid`); + if (existsSync(pidFile)) { + unlinkSync(pidFile); + } +} + +// --------------------------------------------------------------------------- +// Service lifecycle +// --------------------------------------------------------------------------- + +const SERVICE_NAMES = ["telegram-bridge", "cloudflared"] as const; +type ServiceName = (typeof SERVICE_NAMES)[number]; + +function startService( + pidDir: string, + name: ServiceName, + command: string, + args: string[], + env?: Record, +): void { + if (isRunning(pidDir, name)) { + const pid = readPid(pidDir, name); + info(`${name} already running (PID ${String(pid)})`); + return; + } + + // Open a single fd for the log file — mirrors bash `>log 2>&1`. + // Uses child_process.spawn directly because execa's typed API + // does not accept raw file descriptors for stdio. + const logFile = join(pidDir, `${name}.log`); + const logFd = openSync(logFile, "w"); + const subprocess = spawn(command, args, { + detached: true, + stdio: ["ignore", logFd, logFd], + env: { ...process.env, ...env }, + }); + closeSync(logFd); + + // Swallow errors on the detached child (e.g. ENOENT if the command + // doesn't exist) so Node doesn't crash with an unhandled 'error' event. + subprocess.on("error", () => {}); + + const pid = subprocess.pid; + if (pid === undefined) { + warn(`${name} failed to start`); + return; + } + + subprocess.unref(); + writePid(pidDir, name, pid); + info(`${name} started (PID ${String(pid)})`); +} + +/** Poll for process exit after SIGTERM, escalate to SIGKILL if needed. */ +function stopService(pidDir: string, name: ServiceName): void { + const pid = readPid(pidDir, name); + if (pid === null) { + info(`${name} was not running`); + return; + } + + if (!isAlive(pid)) { + info(`${name} was not running`); + removePid(pidDir, name); + return; + } + + // Send SIGTERM + try { + process.kill(pid, "SIGTERM"); + } catch { + // Already dead between the check and the signal + removePid(pidDir, name); + info(`${name} stopped (PID ${String(pid)})`); + return; + } + + // Poll for exit (up to 3 seconds) + const deadline = Date.now() + 3000; + while (Date.now() < deadline && isAlive(pid)) { + // Busy-wait in 100ms increments (synchronous — matches stop being sync) + const start = Date.now(); + while (Date.now() - start < 100) { + /* spin */ + } + } + + // Escalate to SIGKILL if still alive + if (isAlive(pid)) { + try { + process.kill(pid, "SIGKILL"); + } catch { + /* already dead */ + } + } + + removePid(pidDir, name); + info(`${name} stopped (PID ${String(pid)})`); +} + +// --------------------------------------------------------------------------- +// Actions +// --------------------------------------------------------------------------- + +/** Reject sandbox names that could escape the PID directory via path traversal. */ +const SAFE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/; + +function validateSandboxName(name: string): string { + if (!SAFE_NAME_RE.test(name) || name.includes("..")) { + throw new Error(`Invalid sandbox name: ${JSON.stringify(name)}`); + } + return name; +} + +function resolvePidDir(opts: ServiceOptions): string { + const sandbox = validateSandboxName( + opts.sandboxName ?? process.env.NEMOCLAW_SANDBOX ?? process.env.SANDBOX_NAME ?? "default", + ); + return opts.pidDir ?? `/tmp/nemoclaw-services-${sandbox}`; +} + +export function showStatus(opts: ServiceOptions = {}): void { + const pidDir = resolvePidDir(opts); + ensurePidDir(pidDir); + + console.log(""); + for (const svc of SERVICE_NAMES) { + if (isRunning(pidDir, svc)) { + const pid = readPid(pidDir, svc); + console.log(` ${GREEN}●${NC} ${svc} (PID ${String(pid)})`); + } else { + console.log(` ${RED}●${NC} ${svc} (stopped)`); + } + } + console.log(""); + + // Only show tunnel URL if cloudflared is actually running + const logFile = join(pidDir, "cloudflared.log"); + if (isRunning(pidDir, "cloudflared") && existsSync(logFile)) { + const log = readFileSync(logFile, "utf-8"); + const match = /https:\/\/[a-z0-9-]*\.trycloudflare\.com/.exec(log); + if (match) { + info(`Public URL: ${match[0]}`); + } + } +} + +export function stopAll(opts: ServiceOptions = {}): void { + const pidDir = resolvePidDir(opts); + ensurePidDir(pidDir); + stopService(pidDir, "cloudflared"); + stopService(pidDir, "telegram-bridge"); + info("All services stopped."); +} + +export async function startAll(opts: ServiceOptions = {}): Promise { + const pidDir = resolvePidDir(opts); + const dashboardPort = opts.dashboardPort ?? (Number(process.env.DASHBOARD_PORT) || 18789); + // Compiled location: dist/lib/services.js → repo root is 2 levels up + const repoDir = opts.repoDir ?? join(__dirname, "..", ".."); + + if (!process.env.TELEGRAM_BOT_TOKEN) { + warn("TELEGRAM_BOT_TOKEN not set — Telegram bridge will not start."); + warn("Create a bot via @BotFather on Telegram and set the token."); + } else if (!process.env.NVIDIA_API_KEY) { + warn("NVIDIA_API_KEY not set — Telegram bridge will not start."); + warn("Set NVIDIA_API_KEY if you want Telegram requests to reach inference."); + } + + // Warn if no sandbox is ready + try { + const output = execFileSync("openshell", ["sandbox", "list"], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + }); + if (!output.includes("Ready")) { + warn("No sandbox in Ready state. Telegram bridge may not work until sandbox is running."); + } + } catch { + /* openshell not installed or no ready sandbox — skip check */ + } + + ensurePidDir(pidDir); + + // WSL2 ships with broken IPv6 routing — force IPv4-first DNS for bridge processes + if (platform() === "linux") { + const isWSL = + !!process.env.WSL_DISTRO_NAME || + !!process.env.WSL_INTEROP || + (existsSync("/proc/version") && + readFileSync("/proc/version", "utf-8").toLowerCase().includes("microsoft")); + if (isWSL) { + const existing = process.env.NODE_OPTIONS ?? ""; + process.env.NODE_OPTIONS = `${existing ? existing + " " : ""}--dns-result-order=ipv4first`; + info("WSL2 detected — setting --dns-result-order=ipv4first for Node.js bridge processes"); + } + } + + // Telegram bridge (only if both token and API key are set) + if (process.env.TELEGRAM_BOT_TOKEN && process.env.NVIDIA_API_KEY) { + const sandboxName = + opts.sandboxName ?? process.env.NEMOCLAW_SANDBOX ?? process.env.SANDBOX_NAME ?? "default"; + startService( + pidDir, + "telegram-bridge", + "node", + [join(repoDir, "scripts", "telegram-bridge.js")], + { SANDBOX_NAME: sandboxName }, + ); + } + + // cloudflared tunnel + try { + execSync("command -v cloudflared", { + stdio: ["ignore", "ignore", "ignore"], + }); + startService(pidDir, "cloudflared", "cloudflared", [ + "tunnel", + "--url", + `http://localhost:${String(dashboardPort)}`, + ]); + } catch { + warn("cloudflared not found — no public URL. Install: brev-setup.sh or manually."); + } + + // Wait for cloudflared URL + if (isRunning(pidDir, "cloudflared")) { + info("Waiting for tunnel URL..."); + const logFile = join(pidDir, "cloudflared.log"); + for (let i = 0; i < 15; i++) { + if (existsSync(logFile)) { + const log = readFileSync(logFile, "utf-8"); + if (/https:\/\/[a-z0-9-]*\.trycloudflare\.com/.test(log)) { + break; + } + } + await new Promise((resolve) => { + setTimeout(resolve, 1000); + }); + } + } + + // Banner + console.log(""); + console.log(" ┌─────────────────────────────────────────────────────┐"); + console.log(" │ NemoClaw Services │"); + console.log(" │ │"); + + let tunnelUrl = ""; + const cfLogFile = join(pidDir, "cloudflared.log"); + if (isRunning(pidDir, "cloudflared") && existsSync(cfLogFile)) { + const log = readFileSync(cfLogFile, "utf-8"); + const match = /https:\/\/[a-z0-9-]*\.trycloudflare\.com/.exec(log); + if (match) { + tunnelUrl = match[0]; + } + } + + if (tunnelUrl) { + console.log(` │ Public URL: ${tunnelUrl.padEnd(40)}│`); + } + + if (isRunning(pidDir, "telegram-bridge")) { + console.log(" │ Telegram: bridge running │"); + } else { + console.log(" │ Telegram: not started (no token) │"); + } + + console.log(" │ │"); + console.log(" │ Run 'openshell term' to monitor egress approvals │"); + console.log(" └─────────────────────────────────────────────────────┘"); + console.log(""); +} + +// --------------------------------------------------------------------------- +// Exported status helper (useful for programmatic access) +// --------------------------------------------------------------------------- + +export function getServiceStatuses(opts: ServiceOptions = {}): ServiceStatus[] { + const pidDir = resolvePidDir(opts); + ensurePidDir(pidDir); + return SERVICE_NAMES.map((name) => { + const running = isRunning(pidDir, name); + return { + name, + running, + pid: running ? readPid(pidDir, name) : null, + }; + }); +} diff --git a/src/lib/url-utils.test.ts b/src/lib/url-utils.test.ts new file mode 100644 index 000000000..69def4b84 --- /dev/null +++ b/src/lib/url-utils.test.ts @@ -0,0 +1,121 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect } from "vitest"; +// Import from compiled dist/ so coverage is attributed correctly. +import { + compactText, + stripEndpointSuffix, + normalizeProviderBaseUrl, + isLoopbackHostname, + formatEnvAssignment, + parsePolicyPresetEnv, +} from "../../dist/lib/url-utils"; + +describe("compactText", () => { + it("collapses whitespace", () => { + expect(compactText(" hello world ")).toBe("hello world"); + }); + + it("handles empty string", () => { + expect(compactText("")).toBe(""); + }); +}); + +describe("stripEndpointSuffix", () => { + it("strips matching suffix", () => { + expect(stripEndpointSuffix("/v1/chat/completions", ["/chat/completions"])).toBe("/v1"); + }); + + it("returns empty for exact match", () => { + expect(stripEndpointSuffix("/v1", ["/v1"])).toBe(""); + }); + + it("returns pathname when no suffix matches", () => { + expect(stripEndpointSuffix("/api/foo", ["/v1"])).toBe("/api/foo"); + }); +}); + +describe("normalizeProviderBaseUrl", () => { + it("strips OpenAI suffixes", () => { + expect(normalizeProviderBaseUrl("https://api.openai.com/v1/chat/completions", "openai")).toBe( + "https://api.openai.com/v1", + ); + }); + + it("strips Anthropic suffixes", () => { + expect(normalizeProviderBaseUrl("https://api.anthropic.com/v1/messages", "anthropic")).toBe( + "https://api.anthropic.com", + ); + }); + + it("strips trailing slashes", () => { + expect(normalizeProviderBaseUrl("https://example.com/v1/", "openai")).toBe( + "https://example.com/v1", + ); + }); + + it("returns origin for root path", () => { + expect(normalizeProviderBaseUrl("https://example.com/", "openai")).toBe( + "https://example.com", + ); + }); + + it("handles empty input", () => { + expect(normalizeProviderBaseUrl("", "openai")).toBe(""); + }); + + it("handles invalid URL gracefully", () => { + expect(normalizeProviderBaseUrl("not-a-url", "openai")).toBe("not-a-url"); + }); +}); + +describe("isLoopbackHostname", () => { + it("matches localhost", () => { + expect(isLoopbackHostname("localhost")).toBe(true); + }); + + it("matches 127.0.0.1", () => { + expect(isLoopbackHostname("127.0.0.1")).toBe(true); + }); + + it("matches ::1", () => { + expect(isLoopbackHostname("::1")).toBe(true); + }); + + it("matches bracketed IPv6", () => { + expect(isLoopbackHostname("[::1]")).toBe(true); + }); + + it("rejects external hostname", () => { + expect(isLoopbackHostname("example.com")).toBe(false); + }); + + it("handles empty input", () => { + expect(isLoopbackHostname("")).toBe(false); + }); +}); + +describe("formatEnvAssignment", () => { + it("formats name=value", () => { + expect(formatEnvAssignment("FOO", "bar")).toBe("FOO=bar"); + }); +}); + +describe("parsePolicyPresetEnv", () => { + it("parses comma-separated values", () => { + expect(parsePolicyPresetEnv("web,local-inference")).toEqual(["web", "local-inference"]); + }); + + it("trims whitespace", () => { + expect(parsePolicyPresetEnv(" web , local ")).toEqual(["web", "local"]); + }); + + it("filters empty segments", () => { + expect(parsePolicyPresetEnv("web,,local")).toEqual(["web", "local"]); + }); + + it("handles empty string", () => { + expect(parsePolicyPresetEnv("")).toEqual([]); + }); +}); diff --git a/src/lib/url-utils.ts b/src/lib/url-utils.ts new file mode 100644 index 000000000..4f34013c0 --- /dev/null +++ b/src/lib/url-utils.ts @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Pure string utilities for URL normalization, text compaction, and + * formatting helpers used across the CLI. + */ + +export function compactText(value = ""): string { + return String(value).replace(/\s+/g, " ").trim(); +} + +export function stripEndpointSuffix(pathname = "", suffixes: string[] = []): string { + for (const suffix of suffixes) { + if (pathname === suffix) return ""; + if (pathname.endsWith(suffix)) { + return pathname.slice(0, -suffix.length); + } + } + return pathname; +} + +export type EndpointFlavor = "anthropic" | "openai"; + +export function normalizeProviderBaseUrl(value: unknown, flavor: EndpointFlavor): string { + const raw = String(value || "").trim(); + if (!raw) return ""; + + try { + const url = new URL(raw); + url.search = ""; + url.hash = ""; + const suffixes = + flavor === "anthropic" + ? ["/v1/messages", "/v1/models", "/v1", "/messages", "/models"] + : ["/responses", "/chat/completions", "/completions", "/models"]; + let pathname = stripEndpointSuffix(url.pathname.replace(/\/+$/, ""), suffixes); + pathname = pathname.replace(/\/+$/, ""); + url.pathname = pathname || "/"; + return url.pathname === "/" ? url.origin : `${url.origin}${url.pathname}`; + } catch { + return raw.replace(/[?#].*$/, "").replace(/\/+$/, ""); + } +} + +export function isLoopbackHostname(hostname = ""): boolean { + const normalized = String(hostname || "") + .trim() + .toLowerCase() + .replace(/^\[|\]$/g, ""); + return ( + normalized === "localhost" || normalized === "::1" || /^127(?:\.\d{1,3}){3}$/.test(normalized) + ); +} + +export function formatEnvAssignment(name: string, value: string): string { + return `${name}=${value}`; +} + +export function parsePolicyPresetEnv(value: string): string[] { + return (value || "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); +} diff --git a/src/lib/usage-notice.ts b/src/lib/usage-notice.ts new file mode 100644 index 000000000..547f14a4a --- /dev/null +++ b/src/lib/usage-notice.ts @@ -0,0 +1,175 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import noticeConfig from "../../bin/lib/usage-notice.json"; + +export const NOTICE_ACCEPT_FLAG = "--yes-i-accept-third-party-software"; +export const NOTICE_ACCEPT_ENV = "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE"; +export const NOTICE_CONFIG_FILE = path.join(__dirname, "..", "..", "bin", "lib", "usage-notice.json"); + +const OSC8_OPEN = "\u001B]8;;"; +const OSC8_CLOSE = "\u001B]8;;\u001B\\"; +const OSC8_TERM = "\u001B\\"; + +type NoticeLink = { + label?: string; + url?: string; +}; + +type NoticeConfig = { + version: string; + title: string; + referenceUrl?: string; + body?: string[]; + links?: NoticeLink[]; + interactivePrompt: string; +}; + +type PromptFn = (question: string) => Promise; +type WriteLineFn = (line: string) => void; + +type EnsureUsageNoticeConsentOptions = { + nonInteractive?: boolean; + acceptedByFlag?: boolean; + promptFn?: PromptFn | null; + writeLine?: WriteLineFn; +}; + +export function getUsageNoticeStateFile(): string { + return path.join(process.env.HOME || os.homedir(), ".nemoclaw", "usage-notice.json"); +} + +export function loadUsageNoticeConfig(): NoticeConfig { + return noticeConfig as NoticeConfig; +} + +export function hasAcceptedUsageNotice(version: string): boolean { + try { + const saved = JSON.parse(fs.readFileSync(getUsageNoticeStateFile(), "utf8")) as { + acceptedVersion?: string; + }; + return saved?.acceptedVersion === version; + } catch { + return false; + } +} + +export function saveUsageNoticeAcceptance(version: string): void { + const stateFile = getUsageNoticeStateFile(); + const dir = path.dirname(stateFile); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + fs.chmodSync(dir, 0o700); + fs.writeFileSync( + stateFile, + JSON.stringify({ acceptedVersion: version, acceptedAt: new Date().toISOString() }, null, 2), + { mode: 0o600 }, + ); + fs.chmodSync(stateFile, 0o600); +} + +export function supportsTerminalHyperlinks(): boolean { + const tty = process.stderr?.isTTY || process.stdout?.isTTY; + if (!tty) return false; + if (process.env.NO_COLOR) return false; + if (process.env.TERM === "dumb") return false; + return true; +} + +export function formatTerminalHyperlink(label: string, url: string): string { + return `${OSC8_OPEN}${url}${OSC8_TERM}${label}${OSC8_CLOSE}`; +} + +export function printUsageNotice( + config: NoticeConfig = loadUsageNoticeConfig(), + writeLine: WriteLineFn = console.error, +): void { + writeLine(""); + writeLine(` ${config.title}`); + writeLine(" ──────────────────────────────────────────────────"); + for (const line of config.body || []) { + const renderedLine = + /^https?:\/\//.test(line) && supportsTerminalHyperlinks() + ? formatTerminalHyperlink(line, line) + : line; + writeLine(` ${renderedLine}`); + } + for (const link of config.links || []) { + writeLine(""); + const label = + supportsTerminalHyperlinks() && link?.url && link?.label + ? formatTerminalHyperlink(link.url, link.url) + : link?.label || ""; + if (label) { + writeLine(` ${label}`); + } + if (link?.url) { + writeLine(` ${link.url}`); + } + } + writeLine(""); +} + +export async function ensureUsageNoticeConsent({ + nonInteractive = false, + acceptedByFlag = false, + promptFn = null, + writeLine = console.error, +}: EnsureUsageNoticeConsentOptions = {}): Promise { + const config = loadUsageNoticeConfig(); + if (hasAcceptedUsageNotice(config.version)) { + return true; + } + + printUsageNotice(config, writeLine); + + if (nonInteractive) { + if (!acceptedByFlag) { + writeLine( + ` Non-interactive onboarding requires ${NOTICE_ACCEPT_FLAG} or ${NOTICE_ACCEPT_ENV}=1.`, + ); + return false; + } + writeLine( + ` [non-interactive] Third-party software notice accepted via ${NOTICE_ACCEPT_FLAG}.`, + ); + saveUsageNoticeAcceptance(config.version); + return true; + } + + if (!process.stdin.isTTY) { + writeLine( + ` Interactive onboarding requires a TTY. Re-run in a terminal or use --non-interactive with ${NOTICE_ACCEPT_FLAG}.`, + ); + return false; + } + + // credentials is still CJS + // eslint-disable-next-line @typescript-eslint/no-require-imports + const ask = promptFn || require("../../bin/lib/credentials").prompt; + const answer = String(await ask(` ${config.interactivePrompt}`)) + .trim() + .toLowerCase(); + if (answer !== "yes") { + writeLine(" Installation cancelled"); + return false; + } + + saveUsageNoticeAcceptance(config.version); + return true; +} + +export async function cli(args = process.argv.slice(2)): Promise { + const acceptedByFlag = + args.includes(NOTICE_ACCEPT_FLAG) || String(process.env[NOTICE_ACCEPT_ENV] || "") === "1"; + const nonInteractive = args.includes("--non-interactive"); + const ok = await ensureUsageNoticeConsent({ + nonInteractive, + acceptedByFlag, + writeLine: console.error, + }); + process.exit(ok ? 0 : 1); +} diff --git a/src/lib/validation.test.ts b/src/lib/validation.test.ts new file mode 100644 index 000000000..eeceeffe2 --- /dev/null +++ b/src/lib/validation.test.ts @@ -0,0 +1,155 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect } from "vitest"; +// Import from compiled dist/ so coverage is attributed correctly. +import { + classifyValidationFailure, + classifyApplyFailure, + classifySandboxCreateFailure, + validateNvidiaApiKeyValue, + isSafeModelId, +} from "../../dist/lib/validation"; + +describe("classifyValidationFailure", () => { + it("classifies curl failures as transport", () => { + expect(classifyValidationFailure({ curlStatus: 7 })).toEqual({ + kind: "transport", + retry: "retry", + }); + }); + + it("classifies 429 as transport", () => { + expect(classifyValidationFailure({ httpStatus: 429 })).toEqual({ + kind: "transport", + retry: "retry", + }); + }); + + it("classifies 5xx as transport", () => { + expect(classifyValidationFailure({ httpStatus: 502 })).toEqual({ + kind: "transport", + retry: "retry", + }); + }); + + it("classifies 401 as credential", () => { + expect(classifyValidationFailure({ httpStatus: 401 })).toEqual({ + kind: "credential", + retry: "credential", + }); + }); + + it("classifies 403 as credential", () => { + expect(classifyValidationFailure({ httpStatus: 403 })).toEqual({ + kind: "credential", + retry: "credential", + }); + }); + + it("classifies 400 as model", () => { + expect(classifyValidationFailure({ httpStatus: 400 })).toEqual({ + kind: "model", + retry: "model", + }); + }); + + it("classifies model-not-found message as model", () => { + expect(classifyValidationFailure({ message: "model xyz not found" })).toEqual({ + kind: "model", + retry: "model", + }); + }); + + it("classifies 404 as endpoint", () => { + expect(classifyValidationFailure({ httpStatus: 404 })).toEqual({ + kind: "endpoint", + retry: "selection", + }); + }); + + it("classifies unauthorized message as credential", () => { + expect(classifyValidationFailure({ message: "Unauthorized access" })).toEqual({ + kind: "credential", + retry: "credential", + }); + }); + + it("returns unknown for unrecognized failures", () => { + expect(classifyValidationFailure({ httpStatus: 418 })).toEqual({ + kind: "unknown", + retry: "selection", + }); + }); + + it("handles no arguments", () => { + expect(classifyValidationFailure()).toEqual({ kind: "unknown", retry: "selection" }); + }); +}); + +describe("classifyApplyFailure", () => { + it("delegates to classifyValidationFailure", () => { + expect(classifyApplyFailure("unauthorized")).toEqual({ + kind: "credential", + retry: "credential", + }); + }); +}); + +describe("classifySandboxCreateFailure", () => { + it("detects image transfer timeout", () => { + const result = classifySandboxCreateFailure("failed to read image export stream"); + expect(result.kind).toBe("image_transfer_timeout"); + }); + + it("detects connection reset", () => { + const result = classifySandboxCreateFailure("Connection reset by peer"); + expect(result.kind).toBe("image_transfer_reset"); + }); + + it("detects incomplete sandbox creation", () => { + const result = classifySandboxCreateFailure("Created sandbox: test"); + expect(result.kind).toBe("sandbox_create_incomplete"); + expect(result.uploadedToGateway).toBe(true); + }); + + it("detects upload progress", () => { + const result = classifySandboxCreateFailure( + "[progress] Uploaded to gateway\nfailed to read image export stream", + ); + expect(result.uploadedToGateway).toBe(true); + }); + + it("returns unknown for unrecognized output", () => { + const result = classifySandboxCreateFailure("something else happened"); + expect(result.kind).toBe("unknown"); + }); +}); + +describe("validateNvidiaApiKeyValue", () => { + it("returns null for valid key", () => { + expect(validateNvidiaApiKeyValue("nvapi-abc123")).toBeNull(); + }); + + it("rejects empty key", () => { + expect(validateNvidiaApiKeyValue("")).toBeTruthy(); + }); + + it("rejects key without nvapi- prefix", () => { + expect(validateNvidiaApiKeyValue("sk-abc123")).toBeTruthy(); + }); +}); + +describe("isSafeModelId", () => { + it("accepts valid model IDs", () => { + expect(isSafeModelId("nvidia/nemotron-3-super-120b-a12b")).toBe(true); + expect(isSafeModelId("gpt-5.4")).toBe(true); + expect(isSafeModelId("claude-sonnet-4-6")).toBe(true); + }); + + it("rejects IDs with spaces or special chars", () => { + expect(isSafeModelId("model name")).toBe(false); + expect(isSafeModelId("model;rm -rf /")).toBe(false); + expect(isSafeModelId("")).toBe(false); + }); +}); diff --git a/src/lib/validation.ts b/src/lib/validation.ts new file mode 100644 index 000000000..ead13b9d7 --- /dev/null +++ b/src/lib/validation.ts @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Pure validation and failure-classification functions. + * + * No I/O, no side effects — takes strings/numbers in, returns typed results. + */ + +export interface ValidationClassification { + kind: "transport" | "credential" | "model" | "endpoint" | "unknown"; + retry: "retry" | "credential" | "model" | "selection"; +} + +export interface SandboxCreateFailure { + kind: "image_transfer_timeout" | "image_transfer_reset" | "sandbox_create_incomplete" | "unknown"; + uploadedToGateway: boolean; +} + +export function classifyValidationFailure({ + httpStatus = 0, + curlStatus = 0, + message = "", +} = {}): ValidationClassification { + const normalized = String(message).replace(/\s+/g, " ").trim().toLowerCase(); + if (curlStatus) { + return { kind: "transport", retry: "retry" }; + } + if (httpStatus === 429 || (httpStatus >= 500 && httpStatus < 600)) { + return { kind: "transport", retry: "retry" }; + } + if (httpStatus === 401 || httpStatus === 403) { + return { kind: "credential", retry: "credential" }; + } + if (httpStatus === 400) { + return { kind: "model", retry: "model" }; + } + if (/model.+not found|unknown model|unsupported model|bad model/i.test(normalized)) { + return { kind: "model", retry: "model" }; + } + if (httpStatus === 404 || httpStatus === 405) { + return { kind: "endpoint", retry: "selection" }; + } + if (/unauthorized|forbidden|invalid api key|invalid_auth|permission/i.test(normalized)) { + return { kind: "credential", retry: "credential" }; + } + return { kind: "unknown", retry: "selection" }; +} + +export function classifyApplyFailure(message = ""): ValidationClassification { + return classifyValidationFailure({ message }); +} + +export function classifySandboxCreateFailure(output = ""): SandboxCreateFailure { + const text = String(output || ""); + const uploadedToGateway = + /\[progress\]\s+Uploaded to gateway/i.test(text) || + /Image .*available in the gateway/i.test(text); + + if (/failed to read image export stream|Timeout error/i.test(text)) { + return { kind: "image_transfer_timeout", uploadedToGateway }; + } + if (/Connection reset by peer/i.test(text)) { + return { kind: "image_transfer_reset", uploadedToGateway }; + } + if (/Created sandbox:/i.test(text)) { + return { kind: "sandbox_create_incomplete", uploadedToGateway: true }; + } + return { kind: "unknown", uploadedToGateway }; +} + +export function validateNvidiaApiKeyValue(key: string): string | null { + if (!key) { + return " NVIDIA API Key is required."; + } + if (!key.startsWith("nvapi-")) { + return " Invalid key. Must start with nvapi-"; + } + return null; +} + +export function isSafeModelId(value: string): boolean { + return /^[A-Za-z0-9._:/-]+$/.test(value); +} diff --git a/src/lib/version.test.ts b/src/lib/version.test.ts new file mode 100644 index 000000000..66bfbd577 --- /dev/null +++ b/src/lib/version.test.ts @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { getVersion } from "../../dist/lib/version"; + +describe("lib/version", () => { + let testDir: string; + + beforeAll(() => { + testDir = mkdtempSync(join(tmpdir(), "version-test-")); + writeFileSync(join(testDir, "package.json"), JSON.stringify({ version: "1.2.3" })); + }); + + afterAll(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + it("falls back to package.json version when no git and no .version", () => { + expect(getVersion({ rootDir: testDir })).toBe("1.2.3"); + }); + + it("prefers .version file over package.json", () => { + writeFileSync(join(testDir, ".version"), "0.5.0-rc1\n"); + const result = getVersion({ rootDir: testDir }); + expect(result).toBe("0.5.0-rc1"); + rmSync(join(testDir, ".version")); + }); + + it("returns a string", () => { + expect(typeof getVersion({ rootDir: testDir })).toBe("string"); + }); +}); diff --git a/src/lib/version.ts b/src/lib/version.ts new file mode 100644 index 000000000..5dd9ca00f --- /dev/null +++ b/src/lib/version.ts @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +export interface VersionOptions { + /** Override the repo root directory. */ + rootDir?: string; +} + +/** + * Resolve the NemoClaw version from (in order): + * 1. `git describe --tags --match "v*"` — works in dev / source checkouts + * 2. `.version` file at repo root — stamped at publish time + * 3. `package.json` version — hard-coded fallback + */ +export function getVersion(opts: VersionOptions = {}): string { + // Compiled location: dist/lib/version.js → repo root is 2 levels up + const root = opts.rootDir ?? join(__dirname, "..", ".."); + + // 1. Try git (available in dev clones and CI) + try { + const raw = execFileSync("git", ["describe", "--tags", "--match", "v*"], { + cwd: root, + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + if (raw) return raw.replace(/^v/, ""); + } catch { + // no git, or no matching tags — fall through + } + + // 2. Try .version file (stamped by prepublishOnly) + const versionFile = join(root, ".version"); + if (existsSync(versionFile)) { + const ver = readFileSync(versionFile, "utf-8").trim(); + if (ver) return ver; + } + + // 3. Fallback to package.json + const raw = readFileSync(join(root, "package.json"), "utf-8"); + const pkg = JSON.parse(raw) as { version: string }; + return pkg.version; +} diff --git a/test/cli.test.js b/test/cli.test.js index 82dd5ee64..b71cde1c4 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -2,17 +2,29 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, it, expect } from "vitest"; -import { execSync } from "node:child_process"; +import { execSync, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; const CLI = path.join(import.meta.dirname, "..", "bin", "nemoclaw.js"); function run(args) { + return runWithEnv(args); +} + +function runWithEnv(args, env = {}, timeout = 10000) { try { const out = execSync(`node "${CLI}" ${args}`, { encoding: "utf-8", - timeout: 10000, - env: { ...process.env, HOME: "/tmp/nemoclaw-cli-test-" + Date.now() }, + timeout, + env: { + ...process.env, + HOME: "/tmp/nemoclaw-cli-test-" + Date.now(), + NEMOCLAW_HEALTH_POLL_COUNT: "1", + NEMOCLAW_HEALTH_POLL_INTERVAL: "0", + ...env, + }, }); return { code: 0, out }; } catch (err) { @@ -56,12 +68,85 @@ describe("CLI dispatch", () => { expect(r.out.includes("No sandboxes")).toBeTruthy(); }); + it("start does not prompt for NVIDIA_API_KEY before launching local services", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-start-no-key-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + const markerFile = path.join(home, "start-args"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "alpha", + }), + { mode: 0o600 }, + ); + fs.writeFileSync( + path.join(localBin, "bash"), + [ + "#!/bin/sh", + `marker_file=${JSON.stringify(markerFile)}`, + 'printf \'%s\\n\' "$@" > "$marker_file"', + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv("start", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + NVIDIA_API_KEY: "", + TELEGRAM_BOT_TOKEN: "", + }); + + expect(r.code).toBe(0); + expect(r.out).not.toContain("NVIDIA API Key required"); + // Services module now runs in-process (no bash shelling) + expect(r.out).toContain("NemoClaw Services"); + }); + it("unknown onboard option exits 1", () => { const r = run("onboard --non-interactiv"); expect(r.code).toBe(1); expect(r.out.includes("Unknown onboard option")).toBeTruthy(); }); + it("accepts onboard --resume in CLI parsing", () => { + const r = run("onboard --resume --non-interactiv"); + expect(r.code).toBe(1); + expect(r.out.includes("Unknown onboard option(s): --non-interactiv")).toBeTruthy(); + }); + + it("accepts the third-party software flag in onboard CLI parsing", () => { + const r = run("onboard --yes-i-accept-third-party-software --non-interactiv"); + expect(r.code).toBe(1); + expect(r.out.includes("Unknown onboard option(s): --non-interactiv")).toBeTruthy(); + }); + + it("setup forwards unknown options into onboard parsing", () => { + const r = run("setup --non-interactiv"); + expect(r.code).toBe(1); + expect(r.out.includes("deprecated")).toBeTruthy(); + expect(r.out.includes("Unknown onboard option(s): --non-interactiv")).toBeTruthy(); + }); + + it("setup forwards --resume into onboard parsing", () => { + const r = run("setup --resume --non-interactive --yes-i-accept-third-party-software"); + expect(r.code).toBe(1); + expect(r.out.includes("deprecated")).toBeTruthy(); + expect(r.out.includes("No resumable onboarding session was found")).toBeTruthy(); + }); + it("debug --help exits 0 and shows usage", () => { const r = run("debug --help"); expect(r.code).toBe(0); @@ -70,11 +155,12 @@ describe("CLI dispatch", () => { expect(r.out.includes("--output")).toBeTruthy(); }); - it("debug --quick exits 0 and produces diagnostic output", () => { + it("debug --quick exits 0 and produces diagnostic output", { timeout: 15000 }, () => { const r = run("debug --quick"); expect(r.code).toBe(0); expect(r.out.includes("Collecting diagnostics")).toBeTruthy(); expect(r.out.includes("System")).toBeTruthy(); + expect(r.out.includes("Onboard Session")).toBeTruthy(); expect(r.out.includes("Done")).toBeTruthy(); }); @@ -90,4 +176,1800 @@ describe("CLI dispatch", () => { expect(r.out.includes("Troubleshooting")).toBeTruthy(); expect(r.out.includes("nemoclaw debug")).toBeTruthy(); }); + + it("maps --follow to openshell --tail", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-logs-follow-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + const markerFile = path.join(home, "logs-args"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "alpha", + }), + { mode: 0o600 }, + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + `marker_file=${JSON.stringify(markerFile)}`, + 'printf \'%s \' "$@" > "$marker_file"', + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv("alpha logs --follow", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(0); + expect(fs.readFileSync(markerFile, "utf8")).toContain("logs alpha --tail"); + expect(fs.readFileSync(markerFile, "utf8")).not.toContain("--follow"); + }); + + it("destroys the gateway runtime when the last sandbox is removed", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-destroy-last-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + const openshellLog = path.join(home, "openshell.log"); + const bashLog = path.join(home, "bash.log"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "alpha", + }), + { mode: 0o600 }, + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/bin/sh", + `log_file=${JSON.stringify(openshellLog)}`, + 'if [ "$1" = "sandbox" ] && [ "$2" = "list" ]; then', + ' printf "NAME STATUS\\n" >> "$log_file"', + " exit 0", + "fi", + 'printf \'%s\\n\' "$*" >> "$log_file"', + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + fs.writeFileSync( + path.join(localBin, "bash"), + [ + "#!/bin/sh", + `log_file=${JSON.stringify(bashLog)}`, + 'printf \'%s\\n\' "$*" >> "$log_file"', + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv("alpha destroy --yes", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(0); + expect(fs.readFileSync(openshellLog, "utf8")).toContain("sandbox delete alpha"); + expect(fs.readFileSync(openshellLog, "utf8")).toContain("NAME STATUS"); + expect(fs.readFileSync(openshellLog, "utf8")).toContain("forward stop 18789"); + expect(fs.readFileSync(openshellLog, "utf8")).toContain("gateway destroy -g nemoclaw"); + expect(fs.readFileSync(bashLog, "utf8")).toContain("docker volume ls -q --filter"); + }); + + it("keeps the gateway runtime when other sandboxes still exist", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-destroy-shared-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + const openshellLog = path.join(home, "openshell.log"); + const bashLog = path.join(home, "bash.log"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + beta: { + name: "beta", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "alpha", + }), + { mode: 0o600 }, + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/bin/sh", + `log_file=${JSON.stringify(openshellLog)}`, + 'if [ "$1" = "sandbox" ] && [ "$2" = "list" ]; then', + ' printf "NAME STATUS\\nbeta Ready\\n" >> "$log_file"', + ' printf "NAME STATUS\\nbeta Ready\\n"', + " exit 0", + "fi", + 'printf \'%s\\n\' "$*" >> "$log_file"', + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + fs.writeFileSync( + path.join(localBin, "bash"), + [ + "#!/bin/sh", + `log_file=${JSON.stringify(bashLog)}`, + 'printf \'%s\\n\' "$*" >> "$log_file"', + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv("alpha destroy --yes", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(0); + expect(fs.readFileSync(openshellLog, "utf8")).toContain("sandbox delete alpha"); + expect(fs.readFileSync(openshellLog, "utf8")).not.toContain("forward stop 18789"); + expect(fs.readFileSync(openshellLog, "utf8")).not.toContain("gateway destroy -g nemoclaw"); + if (fs.existsSync(bashLog)) { + expect(fs.readFileSync(bashLog, "utf8")).not.toContain("docker volume ls -q --filter"); + } + }); + + it("keeps the gateway runtime when the live gateway still reports sandboxes", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-destroy-live-shared-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + const openshellLog = path.join(home, "openshell.log"); + const bashLog = path.join(home, "bash.log"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "alpha", + }), + { mode: 0o600 }, + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/bin/sh", + `log_file=${JSON.stringify(openshellLog)}`, + 'if [ "$1" = "sandbox" ] && [ "$2" = "list" ]; then', + ' printf "NAME STATUS\\nbeta Ready\\n" >> "$log_file"', + ' printf "NAME STATUS\\nbeta Ready\\n"', + " exit 0", + "fi", + 'printf \'%s\\n\' "$*" >> "$log_file"', + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + fs.writeFileSync( + path.join(localBin, "bash"), + [ + "#!/bin/sh", + `log_file=${JSON.stringify(bashLog)}`, + 'printf \'%s\\n\' "$*" >> "$log_file"', + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv("alpha destroy --yes", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(0); + expect(fs.readFileSync(openshellLog, "utf8")).toContain("sandbox delete alpha"); + expect(fs.readFileSync(openshellLog, "utf8")).toContain("beta Ready"); + expect(fs.readFileSync(openshellLog, "utf8")).not.toContain("forward stop 18789"); + expect(fs.readFileSync(openshellLog, "utf8")).not.toContain("gateway destroy -g nemoclaw"); + if (fs.existsSync(bashLog)) { + expect(fs.readFileSync(bashLog, "utf8")).not.toContain("docker volume ls -q --filter"); + } + }); + + it("fails destroy when openshell sandbox delete returns a real error", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-destroy-failure-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + const openshellLog = path.join(home, "openshell.log"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "alpha", + }), + { mode: 0o600 }, + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/bin/sh", + `log_file=${JSON.stringify(openshellLog)}`, + 'printf \'%s\\n\' "$*" >> "$log_file"', + 'if [ "$1" = "sandbox" ] && [ "$2" = "delete" ]; then', + ' echo "transport error: gateway unavailable" >&2', + " exit 1", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv("alpha destroy --yes", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(1); + expect(r.out).toContain("transport error: gateway unavailable"); + expect(r.out).toContain("Failed to destroy sandbox 'alpha'."); + expect(r.out).not.toContain("Sandbox 'alpha' destroyed"); + + const registryAfter = JSON.parse( + fs.readFileSync(path.join(registryDir, "sandboxes.json"), "utf8"), + ); + expect(registryAfter.sandboxes.alpha).toBeTruthy(); + expect(fs.readFileSync(openshellLog, "utf8")).toContain("sandbox delete alpha"); + expect(fs.readFileSync(openshellLog, "utf8")).not.toContain("gateway destroy -g nemoclaw"); + }); + + it("treats an already-missing sandbox as destroyed and clears the stale registry entry", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-destroy-missing-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + const openshellLog = path.join(home, "openshell.log"); + const bashLog = path.join(home, "bash.log"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "alpha", + }), + { mode: 0o600 }, + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/bin/sh", + `log_file=${JSON.stringify(openshellLog)}`, + 'if [ "$1" = "sandbox" ] && [ "$2" = "delete" ]; then', + ' printf \'%s\\n\' "$*" >> "$log_file"', + ' echo "Error: status: Not Found, message: \\"sandbox not found\\"" >&2', + " exit 1", + "fi", + 'if [ "$1" = "sandbox" ] && [ "$2" = "list" ]; then', + ' printf "NAME STATUS\\n" >> "$log_file"', + ' printf "NAME STATUS\\n"', + " exit 0", + "fi", + 'printf \'%s\\n\' "$*" >> "$log_file"', + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + fs.writeFileSync( + path.join(localBin, "bash"), + [ + "#!/bin/sh", + `log_file=${JSON.stringify(bashLog)}`, + 'printf \'%s\\n\' "$*" >> "$log_file"', + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv("alpha destroy --yes", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(0); + expect(r.out).toContain("already absent from the live gateway"); + expect(r.out).toContain("Sandbox 'alpha' destroyed"); + + const registryAfter = JSON.parse( + fs.readFileSync(path.join(registryDir, "sandboxes.json"), "utf8"), + ); + expect(registryAfter.sandboxes.alpha).toBeFalsy(); + expect(fs.readFileSync(openshellLog, "utf8")).toContain("sandbox delete alpha"); + expect(fs.readFileSync(openshellLog, "utf8")).toContain("forward stop 18789"); + expect(fs.readFileSync(openshellLog, "utf8")).toContain("gateway destroy -g nemoclaw"); + expect(fs.readFileSync(bashLog, "utf8")).toContain("docker volume ls -q --filter"); + }); + + it("passes plain logs through without the tail flag", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-logs-plain-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + const markerFile = path.join(home, "logs-args"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "alpha", + }), + { mode: 0o600 }, + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + `marker_file=${JSON.stringify(markerFile)}`, + 'if [ "$1" = "--version" ]; then', + " echo 'openshell 0.0.16'", + " exit 0", + "fi", + 'printf \'%s \' "$@" > "$marker_file"', + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv("alpha logs", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(0); + expect(fs.readFileSync(markerFile, "utf8")).toContain("logs alpha"); + expect(fs.readFileSync(markerFile, "utf8")).not.toContain("--tail"); + }); + + it("prints upgrade guidance when openshell is too old for nemoclaw logs", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-logs-old-openshell-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "alpha", + }), + { mode: 0o600 }, + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + 'if [ "$1" = "--version" ]; then', + " echo 'openshell 0.0.4'", + " exit 0", + "fi", + "echo \"error: unrecognized subcommand 'logs'\" >&2", + "exit 2", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv("alpha logs --follow", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(1); + expect(r.out.includes("too old or incompatible with `nemoclaw logs`")).toBeTruthy(); + expect(r.out.includes("Upgrade OpenShell by rerunning `nemoclaw onboard`")).toBeTruthy(); + }); + + it("connect does not pre-start a duplicate port forward", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-connect-forward-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + const markerFile = path.join(home, "openshell-calls"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "alpha", + }), + { mode: 0o600 }, + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + `marker_file=${JSON.stringify(markerFile)}`, + 'printf \'%s\\n\' "$*" >> "$marker_file"', + 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', + " echo 'Sandbox:'", + " echo", + " echo ' Id: abc'", + " echo ' Name: alpha'", + " echo ' Namespace: openshell'", + " echo ' Phase: Ready'", + " exit 0", + "fi", + 'if [ "$1" = "sandbox" ] && [ "$2" = "connect" ] && [ "$3" = "alpha" ]; then', + " exit 0", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv("alpha connect", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(0); + const calls = fs.readFileSync(markerFile, "utf8").trim().split("\n").filter(Boolean); + expect(calls).toContain("sandbox get alpha"); + expect(calls).toContain("sandbox connect alpha"); + expect(calls.some((call) => call.startsWith("forward start --background 18789"))).toBe(false); + }); + + it("removes stale registry entries when connect targets a missing live sandbox", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-stale-connect-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "alpha", + }), + { mode: 0o600 }, + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', + " echo 'Error: status: NotFound, message: \"sandbox not found\"' >&2", + " exit 1", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv("alpha connect", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(1); + expect(r.out.includes("Removed stale local registry entry")).toBeTruthy(); + const saved = JSON.parse(fs.readFileSync(path.join(registryDir, "sandboxes.json"), "utf8")); + expect(saved.sandboxes.alpha).toBeUndefined(); + }); + + it("recovers a missing registry entry from the last onboard session during list", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-list-session-recover-")); + const localBin = path.join(home, "bin"); + const nemoclawDir = path.join(home, ".nemoclaw"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(nemoclawDir, { recursive: true }); + fs.writeFileSync( + path.join(nemoclawDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + gamma: { + name: "gamma", + model: "existing-model", + provider: "existing-provider", + gpuEnabled: false, + policies: ["npm"], + }, + }, + defaultSandbox: "gamma", + }), + { mode: 0o600 }, + ); + fs.writeFileSync( + path.join(nemoclawDir, "onboard-session.json"), + JSON.stringify( + { + version: 1, + sessionId: "session-1", + resumable: true, + status: "complete", + mode: "interactive", + startedAt: "2026-03-31T00:00:00.000Z", + updatedAt: "2026-03-31T00:00:00.000Z", + lastStepStarted: "policies", + lastCompletedStep: "policies", + failure: null, + sandboxName: "alpha", + provider: "nvidia-prod", + model: "nvidia/nemotron-3-super-120b-a12b", + endpointUrl: null, + credentialEnv: null, + preferredInferenceApi: null, + nimContainer: null, + policyPresets: ["pypi"], + metadata: { gatewayName: "nemoclaw" }, + steps: { + preflight: { status: "complete", startedAt: null, completedAt: null, error: null }, + gateway: { status: "complete", startedAt: null, completedAt: null, error: null }, + sandbox: { status: "complete", startedAt: null, completedAt: null, error: null }, + provider_selection: { + status: "complete", + startedAt: null, + completedAt: null, + error: null, + }, + inference: { status: "complete", startedAt: null, completedAt: null, error: null }, + openclaw: { status: "complete", startedAt: null, completedAt: null, error: null }, + policies: { status: "complete", startedAt: null, completedAt: null, error: null }, + }, + }, + null, + 2, + ), + { mode: 0o600 }, + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + 'if [ "$1" = "status" ]; then', + " echo 'Server Status'", + " echo", + " echo ' Gateway: nemoclaw'", + " echo ' Status: Connected'", + " exit 0", + "fi", + 'if [ "$1" = "gateway" ] && [ "$2" = "info" ]; then', + " echo 'Gateway Info'", + " echo", + " echo ' Gateway: nemoclaw'", + " exit 0", + "fi", + 'if [ "$1" = "sandbox" ] && [ "$2" = "list" ]; then', + " echo 'No sandboxes found.'", + " exit 0", + "fi", + 'if [ "$1" = "inference" ] && [ "$2" = "get" ]; then', + " exit 0", + "fi", + 'if [ "$1" = "--version" ]; then', + " echo 'openshell 0.0.16'", + " exit 0", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv("list", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(0); + expect( + r.out.includes("Recovered sandbox inventory from the last onboard session."), + ).toBeTruthy(); + expect(r.out.includes("alpha")).toBeTruthy(); + expect(r.out.includes("gamma")).toBeTruthy(); + const saved = JSON.parse(fs.readFileSync(path.join(nemoclawDir, "sandboxes.json"), "utf8")); + expect(saved.sandboxes.alpha).toBeTruthy(); + expect(saved.sandboxes.alpha.policies).toEqual(["pypi"]); + expect(saved.sandboxes.gamma).toBeTruthy(); + expect(saved.defaultSandbox).toBe("gamma"); + }); + + it("imports additional live sandboxes into the registry during list recovery", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-list-live-recover-")); + const localBin = path.join(home, "bin"); + const nemoclawDir = path.join(home, ".nemoclaw"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(nemoclawDir, { recursive: true }); + fs.writeFileSync( + path.join(nemoclawDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + gamma: { + name: "gamma", + model: "existing-model", + provider: "existing-provider", + gpuEnabled: false, + policies: ["npm"], + }, + }, + defaultSandbox: "gamma", + }), + { mode: 0o600 }, + ); + fs.writeFileSync( + path.join(nemoclawDir, "onboard-session.json"), + JSON.stringify( + { + version: 1, + sessionId: "session-1", + resumable: true, + status: "complete", + mode: "interactive", + startedAt: "2026-03-31T00:00:00.000Z", + updatedAt: "2026-03-31T00:00:00.000Z", + lastStepStarted: "policies", + lastCompletedStep: "policies", + failure: null, + sandboxName: "alpha", + provider: "nvidia-prod", + model: "nvidia/nemotron-3-super-120b-a12b", + endpointUrl: null, + credentialEnv: null, + preferredInferenceApi: null, + nimContainer: null, + policyPresets: ["pypi"], + metadata: { gatewayName: "nemoclaw" }, + steps: { + preflight: { status: "complete", startedAt: null, completedAt: null, error: null }, + gateway: { status: "complete", startedAt: null, completedAt: null, error: null }, + sandbox: { status: "complete", startedAt: null, completedAt: null, error: null }, + provider_selection: { + status: "complete", + startedAt: null, + completedAt: null, + error: null, + }, + inference: { status: "complete", startedAt: null, completedAt: null, error: null }, + openclaw: { status: "complete", startedAt: null, completedAt: null, error: null }, + policies: { status: "complete", startedAt: null, completedAt: null, error: null }, + }, + }, + null, + 2, + ), + { mode: 0o600 }, + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + 'if [ "$1" = "status" ]; then', + " echo 'Server Status'", + " echo", + " echo ' Gateway: nemoclaw'", + " echo ' Status: Connected'", + " exit 0", + "fi", + 'if [ "$1" = "gateway" ] && [ "$2" = "info" ]; then', + " echo 'Gateway Info'", + " echo", + " echo ' Gateway: nemoclaw'", + " exit 0", + "fi", + 'if [ "$1" = "sandbox" ] && [ "$2" = "list" ]; then', + " echo 'NAME PHASE'", + " echo 'alpha Ready'", + " echo 'beta Ready'", + " exit 0", + "fi", + 'if [ "$1" = "inference" ] && [ "$2" = "get" ]; then', + " exit 0", + "fi", + 'if [ "$1" = "--version" ]; then', + " echo 'openshell 0.0.16'", + " exit 0", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv("list", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(0); + expect( + r.out.includes("Recovered sandbox inventory from the last onboard session."), + ).toBeTruthy(); + expect( + r.out.includes("Recovered 1 sandbox entry from the live OpenShell gateway."), + ).toBeTruthy(); + expect(r.out.includes("alpha")).toBeTruthy(); + expect(r.out.includes("beta")).toBeTruthy(); + expect(r.out.includes("gamma")).toBeTruthy(); + const saved = JSON.parse(fs.readFileSync(path.join(nemoclawDir, "sandboxes.json"), "utf8")); + expect(saved.sandboxes.alpha).toBeTruthy(); + expect(saved.sandboxes.alpha.policies).toEqual(["pypi"]); + expect(saved.sandboxes.beta).toBeTruthy(); + expect(saved.sandboxes.gamma).toBeTruthy(); + expect(saved.defaultSandbox).toBe("gamma"); + }); + + it("skips invalid recovered sandbox names during list recovery", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-list-invalid-recover-")); + const localBin = path.join(home, "bin"); + const nemoclawDir = path.join(home, ".nemoclaw"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(nemoclawDir, { recursive: true }); + fs.writeFileSync( + path.join(nemoclawDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + gamma: { + name: "gamma", + model: "existing-model", + provider: "existing-provider", + gpuEnabled: false, + policies: ["npm"], + }, + }, + defaultSandbox: "gamma", + }), + { mode: 0o600 }, + ); + fs.writeFileSync( + path.join(nemoclawDir, "onboard-session.json"), + JSON.stringify( + { + version: 1, + sessionId: "session-1", + resumable: true, + status: "complete", + mode: "interactive", + startedAt: "2026-03-31T00:00:00.000Z", + updatedAt: "2026-03-31T00:00:00.000Z", + lastStepStarted: "policies", + lastCompletedStep: "policies", + failure: null, + sandboxName: "Alpha", + provider: "nvidia-prod", + model: "nvidia/nemotron-3-super-120b-a12b", + endpointUrl: null, + credentialEnv: null, + preferredInferenceApi: null, + nimContainer: null, + policyPresets: ["pypi"], + metadata: { gatewayName: "nemoclaw" }, + steps: { + preflight: { status: "complete", startedAt: null, completedAt: null, error: null }, + gateway: { status: "complete", startedAt: null, completedAt: null, error: null }, + sandbox: { status: "complete", startedAt: null, completedAt: null, error: null }, + provider_selection: { + status: "complete", + startedAt: null, + completedAt: null, + error: null, + }, + inference: { status: "complete", startedAt: null, completedAt: null, error: null }, + openclaw: { status: "complete", startedAt: null, completedAt: null, error: null }, + policies: { status: "complete", startedAt: null, completedAt: null, error: null }, + }, + }, + null, + 2, + ), + { mode: 0o600 }, + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + 'if [ "$1" = "status" ]; then', + " echo 'Server Status'", + " echo", + " echo ' Gateway: nemoclaw'", + " echo ' Status: Connected'", + " exit 0", + "fi", + 'if [ "$1" = "gateway" ] && [ "$2" = "info" ]; then', + " echo 'Gateway Info'", + " echo", + " echo ' Gateway: nemoclaw'", + " exit 0", + "fi", + 'if [ "$1" = "sandbox" ] && [ "$2" = "list" ]; then', + " echo 'NAME PHASE'", + " echo 'alpha Ready'", + " echo 'Bad_Name Ready'", + " exit 0", + "fi", + 'if [ "$1" = "inference" ] && [ "$2" = "get" ]; then', + " exit 0", + "fi", + 'if [ "$1" = "--version" ]; then', + " echo 'openshell 0.0.16'", + " exit 0", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv("list", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(0); + expect(r.out.includes("alpha")).toBeTruthy(); + expect(r.out.includes("Bad_Name")).toBeFalsy(); + const saved = JSON.parse(fs.readFileSync(path.join(nemoclawDir, "sandboxes.json"), "utf8")); + expect(saved.sandboxes.alpha).toBeTruthy(); + expect(saved.sandboxes.Bad_Name).toBeUndefined(); + expect(saved.sandboxes.Alpha).toBeUndefined(); + expect(saved.sandboxes.gamma).toBeTruthy(); + }); + + it("connect recovers a named sandbox from the last onboard session when the registry is empty", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-connect-recover-session-")); + const localBin = path.join(home, "bin"); + const nemoclawDir = path.join(home, ".nemoclaw"); + const markerFile = path.join(home, "connect-args"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(nemoclawDir, { recursive: true }); + fs.writeFileSync( + path.join(nemoclawDir, "onboard-session.json"), + JSON.stringify( + { + version: 1, + sessionId: "session-1", + resumable: true, + status: "complete", + mode: "interactive", + startedAt: "2026-03-31T00:00:00.000Z", + updatedAt: "2026-03-31T00:00:00.000Z", + lastStepStarted: "policies", + lastCompletedStep: "policies", + failure: null, + sandboxName: "alpha", + provider: "nvidia-prod", + model: "nvidia/nemotron-3-super-120b-a12b", + endpointUrl: null, + credentialEnv: null, + preferredInferenceApi: null, + nimContainer: null, + policyPresets: null, + metadata: { gatewayName: "nemoclaw" }, + steps: { + preflight: { status: "complete", startedAt: null, completedAt: null, error: null }, + gateway: { status: "complete", startedAt: null, completedAt: null, error: null }, + sandbox: { status: "complete", startedAt: null, completedAt: null, error: null }, + provider_selection: { + status: "complete", + startedAt: null, + completedAt: null, + error: null, + }, + inference: { status: "complete", startedAt: null, completedAt: null, error: null }, + openclaw: { status: "complete", startedAt: null, completedAt: null, error: null }, + policies: { status: "complete", startedAt: null, completedAt: null, error: null }, + }, + }, + null, + 2, + ), + { mode: 0o600 }, + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + `marker_file=${JSON.stringify(markerFile)}`, + 'printf \'%s\\n\' "$*" >> "$marker_file"', + 'if [ "$1" = "status" ]; then', + " echo 'Server Status'", + " echo", + " echo ' Gateway: nemoclaw'", + " echo ' Status: Connected'", + " exit 0", + "fi", + 'if [ "$1" = "gateway" ] && [ "$2" = "info" ]; then', + " echo 'Gateway Info'", + " echo", + " echo ' Gateway: nemoclaw'", + " exit 0", + "fi", + 'if [ "$1" = "sandbox" ] && [ "$2" = "list" ]; then', + " echo 'No sandboxes found.'", + " exit 0", + "fi", + 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', + " echo 'Sandbox:'", + " echo", + " echo ' Id: abc'", + " echo ' Name: alpha'", + " echo ' Namespace: openshell'", + " echo ' Phase: Ready'", + " exit 0", + "fi", + 'if [ "$1" = "sandbox" ] && [ "$2" = "connect" ] && [ "$3" = "alpha" ]; then', + " exit 0", + "fi", + 'if [ "$1" = "--version" ]; then', + " echo 'openshell 0.0.16'", + " exit 0", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv("alpha connect", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(0); + const log = fs.readFileSync(markerFile, "utf8"); + expect(log.includes("sandbox list")).toBeTruthy(); + expect(log.includes("sandbox get alpha")).toBeTruthy(); + expect(log.includes("sandbox connect alpha")).toBeTruthy(); + }); + + it("connect keeps the unknown command path when recovery cannot find the requested sandbox", () => { + const home = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-cli-connect-unknown-after-recovery-"), + ); + const localBin = path.join(home, "bin"); + const nemoclawDir = path.join(home, ".nemoclaw"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(nemoclawDir, { recursive: true }); + fs.writeFileSync( + path.join(nemoclawDir, "onboard-session.json"), + JSON.stringify( + { + version: 1, + sessionId: "session-1", + resumable: true, + status: "complete", + mode: "interactive", + startedAt: "2026-03-31T00:00:00.000Z", + updatedAt: "2026-03-31T00:00:00.000Z", + lastStepStarted: "policies", + lastCompletedStep: "policies", + failure: null, + sandboxName: "alpha", + provider: "nvidia-prod", + model: "nvidia/nemotron-3-super-120b-a12b", + endpointUrl: null, + credentialEnv: null, + preferredInferenceApi: null, + nimContainer: null, + policyPresets: null, + metadata: { gatewayName: "nemoclaw" }, + steps: { + preflight: { status: "complete", startedAt: null, completedAt: null, error: null }, + gateway: { status: "complete", startedAt: null, completedAt: null, error: null }, + sandbox: { status: "complete", startedAt: null, completedAt: null, error: null }, + provider_selection: { + status: "complete", + startedAt: null, + completedAt: null, + error: null, + }, + inference: { status: "complete", startedAt: null, completedAt: null, error: null }, + openclaw: { status: "complete", startedAt: null, completedAt: null, error: null }, + policies: { status: "complete", startedAt: null, completedAt: null, error: null }, + }, + }, + null, + 2, + ), + { mode: 0o600 }, + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + 'if [ "$1" = "status" ]; then', + " echo 'Server Status'", + " echo", + " echo ' Gateway: nemoclaw'", + " echo ' Status: Connected'", + " exit 0", + "fi", + 'if [ "$1" = "gateway" ] && [ "$2" = "info" ]; then', + " echo 'Gateway Info'", + " echo", + " echo ' Gateway: nemoclaw'", + " exit 0", + "fi", + 'if [ "$1" = "sandbox" ] && [ "$2" = "list" ]; then', + " echo 'No sandboxes found.'", + " exit 0", + "fi", + 'if [ "$1" = "--version" ]; then', + " echo 'openshell 0.0.16'", + " exit 0", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv("beta connect", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(1); + expect(r.out.includes("Unknown command: beta")).toBeTruthy(); + expect(r.out.includes("Try: nemoclaw connect")).toBeTruthy(); + }); + + it("preserves SIGINT exit semantics for logs --follow", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-logs-sigint-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "alpha", + }), + { mode: 0o600 }, + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + 'if [ "$1" = "--version" ]; then', + " echo 'openshell 0.0.16'", + " exit 0", + "fi", + "kill -INT $$", + ].join("\n"), + { mode: 0o755 }, + ); + + const result = spawnSync(process.execPath, [CLI, "alpha", "logs", "--follow"], { + cwd: path.join(import.meta.dirname, ".."), + encoding: "utf-8", + env: { ...process.env, HOME: home, PATH: `${localBin}:${process.env.PATH || ""}` }, + }); + + expect(result.status).toBe(130); + }); + + it("keeps registry entries when status hits a gateway-level transport error", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-gateway-error-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "alpha", + }), + { mode: 0o600 }, + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', + " echo 'Error: transport error: handshake verification failed' >&2", + " exit 1", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv( + "alpha status", + { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }, + 10000, + ); + + expect(r.code).toBe(0); + expect(r.out.includes("Could not verify sandbox 'alpha'")).toBeTruthy(); + expect(r.out.includes("gateway identity drift after restart")).toBeTruthy(); + const saved = JSON.parse(fs.readFileSync(path.join(registryDir, "sandboxes.json"), "utf8")); + expect(saved.sandboxes.alpha).toBeTruthy(); + }, 10000); + + it("recovers status after gateway runtime is reattached", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-recover-status-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + const stateFile = path.join(home, "sandbox-get-count"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "alpha", + }), + { mode: 0o600 }, + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + `state_file=${JSON.stringify(stateFile)}`, + 'count=$(cat "$state_file" 2>/dev/null || echo 0)', + 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', + " count=$((count + 1))", + ' echo "$count" > "$state_file"', + ' if [ "$count" -eq 1 ]; then', + " echo 'Error: transport error: Connection refused' >&2", + " exit 1", + " fi", + " echo 'Sandbox: alpha'", + " exit 0", + "fi", + 'if [ "$1" = "status" ]; then', + " echo 'Server Status'", + " echo", + " echo ' Gateway: nemoclaw'", + " echo ' Status: Connected'", + " exit 0", + "fi", + 'if [ "$1" = "gateway" ] && [ "$2" = "info" ] && [ "$3" = "-g" ] && [ "$4" = "nemoclaw" ]; then', + " echo 'Gateway Info'", + " echo", + " echo ' Gateway: nemoclaw'", + " exit 0", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv("alpha status", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(0); + expect(r.out.includes("Recovered NemoClaw gateway runtime")).toBeTruthy(); + expect(r.out.includes("Sandbox: alpha")).toBeTruthy(); + }); + + it("does not treat a different connected gateway as a healthy nemoclaw gateway", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-mixed-gateway-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "alpha", + }), + { mode: 0o600 }, + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', + " echo 'Error: transport error: Connection refused' >&2", + " exit 1", + "fi", + 'if [ "$1" = "status" ]; then', + " echo 'Server Status'", + " echo", + " echo ' Gateway: openshell'", + " echo ' Status: Connected'", + " exit 0", + "fi", + 'if [ "$1" = "gateway" ] && [ "$2" = "info" ] && [ "$3" = "-g" ] && [ "$4" = "nemoclaw" ]; then', + " echo 'Gateway Info'", + " echo", + " echo ' Gateway: nemoclaw'", + " exit 0", + "fi", + 'if [ "$1" = "gateway" ] && [ "$2" = "select" ] && [ "$3" = "nemoclaw" ]; then', + " exit 0", + "fi", + 'if [ "$1" = "gateway" ] && [ "$2" = "start" ] && [ "$3" = "--name" ] && [ "$4" = "nemoclaw" ]; then', + " exit 0", + "fi", + 'if [ "$1" = "inference" ] && [ "$2" = "get" ]; then', + " exit 0", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv( + "alpha status", + { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }, + 10000, + ); + + expect(r.code).toBe(0); + expect(r.out.includes("Recovered NemoClaw gateway runtime")).toBeFalsy(); + expect(r.out.includes("Could not verify sandbox 'alpha'")).toBeTruthy(); + expect(r.out.includes("verify the active gateway")).toBeTruthy(); + }, 10000); + + it("matches ANSI-decorated gateway transport errors when printing lifecycle hints", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-ansi-transport-hint-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "alpha", + }), + { mode: 0o600 }, + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', + " printf '\\033[31mError: trans\\033[0mport error: Connec\\033[33mtion refused\\033[0m\\n' >&2", + " exit 1", + "fi", + 'if [ "$1" = "status" ]; then', + " echo 'Server Status'", + " echo", + " echo ' Gateway: openshell'", + " echo ' Status: Disconnected'", + " exit 0", + "fi", + 'if [ "$1" = "gateway" ] && [ "$2" = "info" ] && [ "$3" = "-g" ] && [ "$4" = "nemoclaw" ]; then', + " printf 'Gateway Info\\n\\n Gateway: openshell\\n'", + " exit 0", + "fi", + 'if [ "$1" = "gateway" ] && [ "$2" = "select" ] && [ "$3" = "nemoclaw" ]; then', + " exit 0", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv( + "alpha status", + { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }, + 10000, + ); + + expect(r.code).toBe(0); + expect(r.out.includes("current gateway/runtime is not reachable")).toBeTruthy(); + }, 10000); + + it("matches ANSI-decorated gateway auth errors when printing lifecycle hints", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-ansi-auth-hint-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "alpha", + }), + { mode: 0o600 }, + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', + " printf '\\033[31mMissing gateway auth\\033[0m token\\n' >&2", + " exit 1", + "fi", + 'if [ "$1" = "status" ]; then', + " echo 'Server Status'", + " echo", + " echo ' Gateway: openshell'", + " echo ' Status: Disconnected'", + " exit 0", + "fi", + 'if [ "$1" = "gateway" ] && [ "$2" = "info" ] && [ "$3" = "-g" ] && [ "$4" = "nemoclaw" ]; then', + " printf 'Gateway Info\\n\\n Gateway: openshell\\n'", + " exit 0", + "fi", + 'if [ "$1" = "gateway" ] && [ "$2" = "select" ] && [ "$3" = "nemoclaw" ]; then', + " exit 0", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv( + "alpha status", + { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }, + 10000, + ); + + expect(r.code).toBe(0); + expect( + r.out.includes("Verify the active gateway and retry after re-establishing the runtime."), + ).toBeTruthy(); + }, 10000); + + it("explains unrecoverable gateway trust rotation after restart", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-identity-drift-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "alpha", + }), + { mode: 0o600 }, + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', + " echo 'Error: transport error: handshake verification failed' >&2", + " exit 1", + "fi", + 'if [ "$1" = "status" ]; then', + " echo 'Server Status'", + " echo", + " echo ' Gateway: nemoclaw'", + " echo ' Status: Connected'", + " exit 0", + "fi", + 'if [ "$1" = "gateway" ] && [ "$2" = "info" ] && [ "$3" = "-g" ] && [ "$4" = "nemoclaw" ]; then', + " echo 'Gateway Info'", + " echo", + " echo ' Gateway: nemoclaw'", + " exit 0", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + + const statusResult = runWithEnv( + "alpha status", + { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }, + 10000, + ); + expect(statusResult.code).toBe(0); + expect(statusResult.out.includes("gateway trust material rotated after restart")).toBeTruthy(); + expect(statusResult.out.includes("cannot be reattached safely")).toBeTruthy(); + + const connectResult = runWithEnv("alpha connect", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + expect(connectResult.code).toBe(1); + expect(connectResult.out.includes("gateway trust material rotated after restart")).toBeTruthy(); + expect(connectResult.out.includes("Recreate this sandbox")).toBeTruthy(); + }); + + it("explains when gateway metadata exists but the restarted API is still refusing connections", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-gateway-unreachable-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "alpha", + }), + { mode: 0o600 }, + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', + " echo 'Error: transport error: Connection refused' >&2", + " exit 1", + "fi", + 'if [ "$1" = "status" ]; then', + " echo 'Server Status'", + " echo", + " echo ' Gateway: nemoclaw'", + " echo ' Server: https://127.0.0.1:8080'", + " echo 'Error: client error (Connect)' >&2", + " echo 'Connection refused (os error 111)' >&2", + " exit 1", + "fi", + 'if [ "$1" = "gateway" ] && [ "$2" = "info" ] && [ "$3" = "-g" ] && [ "$4" = "nemoclaw" ]; then', + " echo 'Gateway Info'", + " echo", + " echo ' Gateway: nemoclaw'", + " exit 0", + "fi", + 'if [ "$1" = "gateway" ] && [ "$2" = "select" ] && [ "$3" = "nemoclaw" ]; then', + " exit 0", + "fi", + 'if [ "$1" = "gateway" ] && [ "$2" = "start" ] && [ "$3" = "--name" ] && [ "$4" = "nemoclaw" ]; then', + " exit 0", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + + const statusResult = runWithEnv( + "alpha status", + { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }, + 10000, + ); + expect(statusResult.code).toBe(0); + expect( + statusResult.out.includes("gateway is still refusing connections after restart"), + ).toBeTruthy(); + expect( + statusResult.out.includes("Retry `openshell gateway start --name nemoclaw`"), + ).toBeTruthy(); + + const connectResult = runWithEnv("alpha connect", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + expect(connectResult.code).toBe(1); + expect( + connectResult.out.includes("gateway is still refusing connections after restart"), + ).toBeTruthy(); + expect(connectResult.out.includes("If the gateway never becomes healthy")).toBeTruthy(); + }, 10000); + + it("explains when the named gateway is no longer configured after restart or rebuild", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-gateway-missing-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "alpha", + }), + { mode: 0o600 }, + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', + " echo 'Error: transport error: Connection refused' >&2", + " exit 1", + "fi", + 'if [ "$1" = "status" ]; then', + " echo 'Gateway Status'", + " echo", + " echo ' Status: No gateway configured.'", + " exit 0", + "fi", + 'if [ "$1" = "gateway" ] && [ "$2" = "info" ] && [ "$3" = "-g" ] && [ "$4" = "nemoclaw" ]; then', + " exit 1", + "fi", + 'if [ "$1" = "gateway" ] && [ "$2" = "select" ] && [ "$3" = "nemoclaw" ]; then', + " exit 0", + "fi", + 'if [ "$1" = "gateway" ] && [ "$2" = "start" ] && [ "$3" = "--name" ] && [ "$4" = "nemoclaw" ]; then', + " exit 1", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + + const statusResult = runWithEnv( + "alpha status", + { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }, + 10000, + ); + expect(statusResult.code).toBe(0); + expect( + statusResult.out.includes("gateway is no longer configured after restart/rebuild"), + ).toBeTruthy(); + expect(statusResult.out.includes("Start the gateway again")).toBeTruthy(); + }, 10000); +}); + +describe("list shows live gateway inference", () => { + it("prefers live inference model/provider over stale registry values", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-list-live-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + // Registry has no model/provider (mimics post-onboard before inference setup) + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + test: { + name: "test", + model: null, + provider: null, + gpuEnabled: true, + policies: ["pypi", "npm"], + }, + }, + defaultSandbox: "test", + }), + { mode: 0o600 }, + ); + // Stub openshell: inference get returns live provider/model + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + 'if [ "$1" = "inference" ] && [ "$2" = "get" ]; then', + " echo 'Gateway inference:'", + " echo ' Provider: nvidia-prod'", + " echo ' Model: nvidia/nemotron-3-super-120b-a12b'", + " echo ' Version: 1'", + " exit 0", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv("list", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(0); + expect(r.out).toContain("nvidia/nemotron-3-super-120b-a12b"); + expect(r.out).toContain("nvidia-prod"); + expect(r.out).not.toContain("unknown"); + }); + + it("falls back to registry values when openshell inference get fails", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-list-fallback-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + test: { + name: "test", + model: "llama3.2:1b", + provider: "ollama-local", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "test", + }), + { mode: 0o600 }, + ); + // Stub openshell: inference get fails + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + 'if [ "$1" = "inference" ] && [ "$2" = "get" ]; then', + " exit 1", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv("list", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(0); + expect(r.out).toContain("llama3.2:1b"); + expect(r.out).toContain("ollama-local"); + }); }); diff --git a/test/credential-exposure.test.js b/test/credential-exposure.test.js index 08f880a9d..828432633 100644 --- a/test/credential-exposure.test.js +++ b/test/credential-exposure.test.js @@ -11,21 +11,8 @@ import fs from "node:fs"; import path from "node:path"; import { describe, it, expect } from "vitest"; -const ONBOARD_JS = path.join( - import.meta.dirname, - "..", - "bin", - "lib", - "onboard.js", -); -const RUNNER_TS = path.join( - import.meta.dirname, - "..", - "nemoclaw", - "src", - "blueprint", - "runner.ts", -); +const ONBOARD_JS = path.join(import.meta.dirname, "..", "bin", "lib", "onboard.js"); +const RUNNER_TS = path.join(import.meta.dirname, "..", "nemoclaw", "src", "blueprint", "runner.ts"); // Matches --credential followed by a value containing "=" (i.e. KEY=VALUE). // Catches quoted KEY=VALUE patterns in JS and Python f-string interpolation. @@ -87,7 +74,7 @@ describe("credential exposure in process arguments", () => { const src = fs.readFileSync(ONBOARD_JS, "utf-8"); expect(src).toMatch(/function getCurlTimingArgs\(\)/); - expect(src).toMatch(/--connect-timeout 5/); - expect(src).toMatch(/--max-time 20/); + expect(src).toMatch(/"--connect-timeout", "10"/); + expect(src).toMatch(/"--max-time", "60"/); }); }); diff --git a/test/credentials.test.js b/test/credentials.test.js index 932092f11..d84c04701 100644 --- a/test/credentials.test.js +++ b/test/credentials.test.js @@ -2,11 +2,68 @@ // SPDX-License-Identifier: Apache-2.0 import fs from "node:fs"; -import { describe, it, expect } from "vitest"; +import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +async function importCredentialsModule(home) { + vi.resetModules(); + vi.doUnmock("fs"); + vi.doUnmock("child_process"); + vi.doUnmock("readline"); + vi.stubEnv("HOME", home); + const module = await import("../bin/lib/credentials.js"); + return module.default ?? module; +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + vi.unstubAllEnvs(); +}); describe("credential prompts", () => { + it("loads, normalizes, and saves credentials from disk", async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-creds-")); + const credentials = await importCredentialsModule(home); + + expect(credentials.loadCredentials()).toEqual({}); + + credentials.saveCredential("TEST_API_KEY", " nvapi-saved-key \r\n"); + + expect(credentials.CREDS_DIR).toBe(path.join(home, ".nemoclaw")); + expect(credentials.CREDS_FILE).toBe(path.join(home, ".nemoclaw", "credentials.json")); + expect(credentials.loadCredentials()).toEqual({ TEST_API_KEY: "nvapi-saved-key" }); + expect(credentials.getCredential("TEST_API_KEY")).toBe("nvapi-saved-key"); + + const saved = JSON.parse( + fs.readFileSync(path.join(home, ".nemoclaw", "credentials.json"), "utf-8"), + ); + expect(saved).toEqual({ TEST_API_KEY: "nvapi-saved-key" }); + }); + + it("prefers environment credentials and ignores malformed credential files", async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-creds-")); + fs.mkdirSync(path.join(home, ".nemoclaw"), { recursive: true }); + fs.writeFileSync(path.join(home, ".nemoclaw", "credentials.json"), "{not-json"); + + const credentials = await importCredentialsModule(home); + expect(credentials.loadCredentials()).toEqual({}); + + vi.stubEnv("TEST_API_KEY", " nvapi-from-env \n"); + expect(credentials.getCredential("TEST_API_KEY")).toBe("nvapi-from-env"); + }); + + it("returns null for missing or blank credential values", async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-creds-")); + const credentials = await importCredentialsModule(home); + + credentials.saveCredential("EMPTY_VALUE", " \r\n "); + expect(credentials.getCredential("MISSING_VALUE")).toBe(null); + expect(credentials.getCredential("EMPTY_VALUE")).toBe(null); + }); + it("exits cleanly when answers are staged through a pipe", () => { const script = ` set -euo pipefail @@ -18,7 +75,7 @@ describe("credential prompts", () => { sleep 1 printf 'n\\n' } > "$pipe" & - node -e 'const { prompt } = require(${JSON.stringify(path.join(import.meta.dirname, "..", "bin", "lib", "credentials"))}); (async()=>{ await prompt("first: "); await prompt("second: "); })().catch(err=>{ console.error(err); process.exit(1); });' < "$pipe" + ${JSON.stringify(process.execPath)} -e 'const { prompt } = require(${JSON.stringify(path.join(import.meta.dirname, "..", "bin", "lib", "credentials"))}); (async()=>{ await prompt("first: "); await prompt("second: "); })().catch(err=>{ console.error(err); process.exit(1); });' < "$pipe" `; const result = spawnSync("bash", ["-lc", script], { @@ -33,11 +90,45 @@ describe("credential prompts", () => { it("settles the outer prompt promise on secret prompt errors", () => { const source = fs.readFileSync( path.join(import.meta.dirname, "..", "bin", "lib", "credentials.js"), - "utf-8" + "utf-8", ); expect(source).toMatch(/return new Promise\(\(resolve, reject\) => \{/); expect(source).toMatch(/reject\(err\);\s*process\.kill\(process\.pid, "SIGINT"\);/); expect(source).toMatch(/reject\(err\);\s*\}\);/); }); + + it("re-raises SIGINT from standard readline prompts instead of treating it like an empty answer", () => { + const source = fs.readFileSync( + path.join(import.meta.dirname, "..", "bin", "lib", "credentials.js"), + "utf-8", + ); + + expect(source).toContain('rl.on("SIGINT"'); + expect(source).toContain('new Error("Prompt interrupted")'); + expect(source).toContain('process.kill(process.pid, "SIGINT")'); + }); + + it("normalizes credential values and keeps prompting on invalid NVIDIA API key prefixes", async () => { + const credentials = await importCredentialsModule("/tmp"); + expect(credentials.normalizeCredentialValue(" nvapi-good-key\r\n")).toBe("nvapi-good-key"); + + const source = fs.readFileSync( + path.join(import.meta.dirname, "..", "bin", "lib", "credentials.js"), + "utf-8", + ); + expect(source).toMatch(/while \(true\) \{/); + expect(source).toMatch(/Invalid key\. Must start with nvapi-/); + expect(source).toMatch(/continue;/); + }); + + it("masks secret input with asterisks while preserving the underlying value", () => { + const source = fs.readFileSync( + path.join(import.meta.dirname, "..", "bin", "lib", "credentials.js"), + "utf-8", + ); + + expect(source).toContain('output.write("*")'); + expect(source).toContain('output.write("\\b \\b")'); + }); }); diff --git a/test/dns-proxy.test.js b/test/dns-proxy.test.js new file mode 100644 index 000000000..d5066ee11 --- /dev/null +++ b/test/dns-proxy.test.js @@ -0,0 +1,122 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; + +const SETUP_DNS_PROXY = path.join(import.meta.dirname, "..", "scripts", "setup-dns-proxy.sh"); +const RUNTIME_SH = path.join(import.meta.dirname, "..", "scripts", "lib", "runtime.sh"); +const FIX_COREDNS = path.join(import.meta.dirname, "..", "scripts", "fix-coredns.sh"); + +describe("setup-dns-proxy.sh", () => { + it("exists and is executable", () => { + const stat = fs.statSync(SETUP_DNS_PROXY); + expect(stat.isFile()).toBe(true); + expect(stat.mode & 0o100).toBeTruthy(); + }); + + it("sources runtime.sh successfully", () => { + const result = spawnSync("bash", ["-c", `source "${RUNTIME_SH}"; echo ok`], { + encoding: /** @type {const} */ ("utf-8"), + env: { ...process.env }, + }); + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe("ok"); + }); + + it("exits with usage when no sandbox name provided", () => { + const result = spawnSync("bash", [SETUP_DNS_PROXY, "nemoclaw"], { + encoding: /** @type {const} */ ("utf-8"), + env: { ...process.env }, + }); + expect(result.status).not.toBe(0); + expect(result.stderr + result.stdout).toMatch(/Usage:/i); + }); + + it("discovers CoreDNS service IP and veth gateway dynamically", () => { + const content = fs.readFileSync(SETUP_DNS_PROXY, "utf-8"); + expect(content).toContain("VETH_GW"); + expect(content).toContain("10.200.0.1"); + }); + + it("adds iptables rule to allow UDP DNS from sandbox", () => { + const content = fs.readFileSync(SETUP_DNS_PROXY, "utf-8"); + expect(content).toContain("iptables"); + expect(content).toContain("-p udp"); + expect(content).toContain("--dport 53"); + expect(content).toContain("ACCEPT"); + }); + + it("deploys a Python DNS forwarder to the pod", () => { + const content = fs.readFileSync(SETUP_DNS_PROXY, "utf-8"); + expect(content).toContain("dns-proxy.py"); + expect(content).toContain("socket.SOCK_DGRAM"); + expect(content).toContain("kctl exec"); + }); + + it("uses kubectl exec (not nsenter) to launch the forwarder", () => { + const content = fs.readFileSync(SETUP_DNS_PROXY, "utf-8"); + expect(content).toContain("kctl exec"); + expect(content).toContain("nohup python3"); + const codeLines = content.split("\n").filter((l) => !l.trimStart().startsWith("#")); + expect(codeLines.join("\n")).not.toContain("nsenter"); + }); + + it("uses grep -F for fixed-string sandbox name matching", () => { + const content = fs.readFileSync(SETUP_DNS_PROXY, "utf-8"); + expect(content).toContain("grep -F"); + }); + + it("discovers CoreDNS pod IP via kube-dns endpoints", () => { + const content = fs.readFileSync(SETUP_DNS_PROXY, "utf-8"); + expect(content).toContain("get endpoints kube-dns"); + expect(content).toContain("kube-system"); + }); + + it("verifies the forwarder started after launch", () => { + const content = fs.readFileSync(SETUP_DNS_PROXY, "utf-8"); + expect(content).toContain("dns-proxy.pid"); + expect(content).toContain("dns-proxy.log"); + }); + + it("performs runtime verification of resolv.conf, iptables, and DNS resolution", () => { + const content = fs.readFileSync(SETUP_DNS_PROXY, "utf-8"); + expect(content).toContain("cat /etc/resolv.conf"); + expect(content).toContain("iptables -C OUTPUT"); + expect(content).toContain("getent hosts"); + expect(content).toContain("VERIFY_PASS"); + expect(content).toContain("VERIFY_FAIL"); + }); +}); + +describe("fix-coredns.sh", () => { + it("exists and is executable", () => { + const stat = fs.statSync(FIX_COREDNS); + expect(stat.isFile()).toBe(true); + expect(stat.mode & 0o100).toBeTruthy(); + }); + + it("works with any Docker host (not Colima-specific)", () => { + const content = fs.readFileSync(FIX_COREDNS, "utf-8"); + expect(content).not.toContain("find_colima_docker_socket"); + expect(content).toContain("detect_docker_host"); + }); + + it("resolves systemd-resolved upstreams when resolv.conf is loopback-only", () => { + const content = fs.readFileSync(FIX_COREDNS, "utf-8"); + expect(content).toContain("resolvectl"); + expect(content).toContain("Current DNS Server"); + }); + + it("falls back to 8.8.8.8 only as last resort", () => { + const content = fs.readFileSync(FIX_COREDNS, "utf-8"); + const lines = content.split("\n"); + const resolvectlLine = lines.findIndex((l) => l.includes("resolvectl")); + const fallbackLine = lines.findIndex((l) => l.includes('UPSTREAM_DNS="8.8.8.8"')); + expect(resolvectlLine).toBeGreaterThan(-1); + expect(fallbackLine).toBeGreaterThan(-1); + expect(fallbackLine).toBeGreaterThan(resolvectlLine); + }); +}); diff --git a/test/e2e-gateway-isolation.sh b/test/e2e-gateway-isolation.sh index cee6763bb..cb4c8d698 100755 --- a/test/e2e-gateway-isolation.sh +++ b/test/e2e-gateway-isolation.sh @@ -125,8 +125,8 @@ fi # ── Test 7: Entrypoint PATH is locked to system dirs ───────────── info "7. Entrypoint locks PATH to system directories" -# Run the entrypoint preamble (up to the PATH export) and verify the result -OUT=$(run_as_root "bash -c 'source <(head -21 /usr/local/bin/nemoclaw-start) 2>/dev/null; echo \$PATH'") +# Walk the entrypoint line-by-line, eval only export lines, stop after PATH. +OUT=$(run_as_root "bash -c 'while IFS= read -r line; do case \"\$line\" in export\\ *) eval \"\$line\" 2>/dev/null;; esac; case \"\$line\" in \"export PATH=\"*) break;; esac; done < /usr/local/bin/nemoclaw-start; echo \$PATH'") if echo "$OUT" | grep -q "^/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin$"; then pass "PATH is locked to system directories" else @@ -159,9 +159,19 @@ else fail "symlink targets wrong:$FAILED_LINKS" fi -# ── Test 10: Sandbox user cannot kill gateway-user processes ───── +# ── Test 10: iptables is installed (required for network policy enforcement) ── -info "10. Sandbox user cannot kill gateway-user processes" +info "10. iptables is installed" +OUT=$(run_as_root "iptables --version 2>&1") +if echo "$OUT" | grep -q "iptables v"; then + pass "iptables installed: $OUT" +else + fail "iptables not found — sandbox network policies will not be enforced: $OUT" +fi + +# ── Test 11: Sandbox user cannot kill gateway-user processes ───── + +info "11. Sandbox user cannot kill gateway-user processes" # Start a dummy process as gateway, try to kill it as sandbox OUT=$(docker run --rm --entrypoint "" "$IMAGE" bash -c ' gosu gateway sleep 60 & @@ -177,6 +187,37 @@ else fail "sandbox CAN kill gateway processes: $OUT" fi +# ── Test 12: Dangerous capabilities are dropped by entrypoint ──── + +info "12. Entrypoint drops dangerous capabilities from bounding set" +# Run capsh directly with the same --drop flags as the entrypoint, then +# check CapBnd. This avoids running the full entrypoint which starts +# gateway services that fail in CI without a running OpenShell environment. +# Extract the --drop list from the entrypoint to stay in sync. +DROP_LIST=$(run_as_root "grep -oP '(?<=--drop=)[^ \\\\]+' /usr/local/bin/nemoclaw-start") +if [ -z "$DROP_LIST" ]; then + fail "could not extract --drop list from entrypoint" +else + OUT=$(run_as_root "capsh --drop=${DROP_LIST} -- -c ' + CAP_BND=\$(grep \"^CapBnd:\" /proc/self/status | awk \"{print \\\$2}\") + echo \"CapBnd=\$CAP_BND\" + BND_DEC=\$((16#\$CAP_BND)) + NET_RAW_BIT=\$((1 << 13)) + if [ \$((BND_DEC & NET_RAW_BIT)) -ne 0 ]; then + echo \"DANGEROUS: cap_net_raw present\" + else + echo \"SAFE: cap_net_raw dropped\" + fi + '") + if echo "$OUT" | grep -q "SAFE: cap_net_raw dropped"; then + pass "entrypoint drops dangerous capabilities (cap_net_raw not in bounding set)" + elif echo "$OUT" | grep -q "DANGEROUS"; then + fail "cap_net_raw still present after capsh drop: $OUT" + else + fail "could not verify capability state: $OUT" + fi +fi + # ── Summary ────────────────────────────────────────────────────── echo "" diff --git a/test/e2e/brev-e2e.test.js b/test/e2e/brev-e2e.test.js index b9c4e0a17..ce67e8943 100644 --- a/test/e2e/brev-e2e.test.js +++ b/test/e2e/brev-e2e.test.js @@ -4,9 +4,10 @@ /** * Ephemeral Brev E2E test suite. * - * Creates a fresh Brev instance, bootstraps it, runs E2E tests remotely, - * then tears it down. Intended to be run from CI via: + * Creates a fresh Brev instance (via launchable or bare CPU), bootstraps it, + * runs E2E tests remotely, then tears it down. * + * Intended to be run from CI via: * npx vitest run --project e2e-brev * * Required env vars: @@ -16,8 +17,13 @@ * INSTANCE_NAME — Brev instance name (e.g. pr-156-test) * * Optional env vars: - * TEST_SUITE — which test to run: full (default), credential-sanitization, all - * BREV_CPU — CPU spec (default: 4x16) + * TEST_SUITE — which test to run: full (default), credential-sanitization, telegram-injection, all + * USE_LAUNCHABLE — "1" (default) to use CI launchable, "0" for bare brev create + brev-setup.sh + * LAUNCHABLE_SETUP_SCRIPT — URL to setup script for launchable path (default: brev-launchable-ci-cpu.sh on main) + * BREV_MIN_VCPU — Minimum vCPUs for CPU instance (default: 4) + * BREV_MIN_RAM — Minimum RAM in GB for CPU instance (default: 16) + * BREV_PROVIDER — Cloud provider filter for brev search (default: gcp) + * BREV_MIN_DISK — Minimum disk size in GB (default: 50) */ import { describe, it, expect, beforeAll, afterAll } from "vitest"; @@ -26,11 +32,29 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import path from "node:path"; -const BREV_CPU = process.env.BREV_CPU || "4x16"; +// Instance configuration +const BREV_MIN_VCPU = parseInt(process.env.BREV_MIN_VCPU || "4", 10); +const BREV_MIN_RAM = parseInt(process.env.BREV_MIN_RAM || "16", 10); +const BREV_PROVIDER = process.env.BREV_PROVIDER || "gcp"; +const BREV_MIN_DISK = parseInt(process.env.BREV_MIN_DISK || "50", 10); const INSTANCE_NAME = process.env.INSTANCE_NAME; const TEST_SUITE = process.env.TEST_SUITE || "full"; const REPO_DIR = path.resolve(import.meta.dirname, "../.."); +// Launchable configuration +// CI-Ready CPU setup script: pre-bakes Docker, Node.js, OpenShell CLI, npm deps, Docker images. +// The Brev CLI (v0.6.322+) uses `brev search cpu | brev create --startup-script @file`. +// Default: use the repo-local script (hermetic — always matches the checked-out branch). +// Override via LAUNCHABLE_SETUP_SCRIPT env var to test a remote URL instead. +const DEFAULT_SETUP_SCRIPT_PATH = + process.env.LAUNCHABLE_SETUP_SCRIPT || + path.join(REPO_DIR, "scripts", "brev-launchable-ci-cpu.sh"); +const USE_LAUNCHABLE = !["0", "false"].includes(process.env.USE_LAUNCHABLE?.toLowerCase()); + +// Sentinel file written by brev-launchable-ci-cpu.sh when setup is complete. +// More reliable than grepping log files. +const LAUNCHABLE_SENTINEL = "/var/run/nemoclaw-launchable-ready"; + let remoteDir; let instanceCreated = false; @@ -44,41 +68,39 @@ function brev(...args) { }).trim(); } -function ssh(cmd, { timeout = 120_000 } = {}) { - // Use single quotes to prevent local shell expansion of remote commands +function ssh(cmd, { timeout = 120_000, stream = false } = {}) { const escaped = cmd.replace(/'/g, "'\\''"); - return execSync( + /** @type {import("child_process").StdioOptions} */ + const stdio = stream ? ["inherit", "inherit", "inherit"] : ["pipe", "pipe", "pipe"]; + const result = execSync( `ssh -o StrictHostKeyChecking=no -o LogLevel=ERROR "${INSTANCE_NAME}" '${escaped}'`, - { encoding: "utf-8", timeout, stdio: ["pipe", "pipe", "pipe"] }, - ).trim(); + { encoding: "utf-8", timeout, stdio }, + ); + return stream ? "" : result.trim(); } +/** + * Escape a value for safe inclusion in a single-quoted shell string. + * Replaces single quotes with the shell-safe sequence: '\'' + */ function shellEscape(value) { - return value.replace(/'/g, "'\\''"); + return String(value).replace(/'/g, "'\\''"); } -/** Run a command on the remote VM with secrets passed via stdin (not CLI args). */ -function sshWithSecrets(cmd, { timeout = 600_000 } = {}) { - const secretPreamble = [ +/** Run a command on the remote VM with env vars set for NemoClaw. */ +function sshEnv(cmd, { timeout = 600_000, stream = false } = {}) { + const envPrefix = [ `export NVIDIA_API_KEY='${shellEscape(process.env.NVIDIA_API_KEY)}'`, `export GITHUB_TOKEN='${shellEscape(process.env.GITHUB_TOKEN)}'`, `export NEMOCLAW_NON_INTERACTIVE=1`, + `export NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1`, `export NEMOCLAW_SANDBOX_NAME=e2e-test`, - ].join("\n"); - - // Pipe secrets via stdin so they don't appear in ps/process listings - return execSync( - `ssh -o StrictHostKeyChecking=no -o LogLevel=ERROR "${INSTANCE_NAME}" 'eval "$(cat)" && ${cmd.replace(/'/g, "'\\''")}'`, - { - encoding: "utf-8", - timeout, - input: secretPreamble, - stdio: ["pipe", "pipe", "pipe"], - }, - ).trim(); + ].join(" && "); + + return ssh(`${envPrefix} && ${cmd}`, { timeout, stream }); } -function waitForSsh(maxAttempts = 60, intervalMs = 5_000) { +function waitForSsh(maxAttempts = 90, intervalMs = 5_000) { for (let i = 1; i <= maxAttempts; i++) { try { ssh("echo ok", { timeout: 10_000 }); @@ -86,22 +108,89 @@ function waitForSsh(maxAttempts = 60, intervalMs = 5_000) { } catch { if (i === maxAttempts) throw new Error(`SSH not ready after ${maxAttempts} attempts`); if (i % 5 === 0) { - try { brev("refresh"); } catch { /* ignore */ } + try { + brev("refresh"); + } catch { + /* ignore */ + } } execSync(`sleep ${intervalMs / 1000}`); } } } +/** + * Wait for the launchable setup script to finish by checking a sentinel file. + * Much more reliable than grepping log files. + */ +function waitForLaunchableReady(maxWaitMs = 1_200_000, pollIntervalMs = 15_000) { + const start = Date.now(); + const elapsed = () => `${Math.round((Date.now() - start) / 1000)}s`; + let consecutiveSshFailures = 0; + + while (Date.now() - start < maxWaitMs) { + try { + const result = ssh(`test -f ${LAUNCHABLE_SENTINEL} && echo READY || echo PENDING`, { + timeout: 15_000, + }); + consecutiveSshFailures = 0; // reset on success + if (result.includes("READY")) { + console.log(`[${elapsed()}] Launchable setup complete (sentinel file found)`); + return; + } + // Show progress from the setup log + try { + const tail = ssh("tail -2 /tmp/launch-plugin.log 2>/dev/null || echo '(no log yet)'", { + timeout: 10_000, + }); + console.log(`[${elapsed()}] Setup still running... ${tail.replace(/\n/g, " | ")}`); + } catch { + /* ignore */ + } + } catch { + consecutiveSshFailures++; + console.log( + `[${elapsed()}] Setup poll: SSH command failed (${consecutiveSshFailures} consecutive), retrying...`, + ); + // Brev VMs sometimes reboot during setup (kernel upgrades, etc.) + // Refresh the SSH config every 3 consecutive failures to pick up + // new IP/port assignments after a reboot. + if (consecutiveSshFailures % 3 === 0) { + console.log( + `[${elapsed()}] Refreshing brev SSH config after ${consecutiveSshFailures} failures...`, + ); + try { + brev("refresh"); + } catch { + /* ignore */ + } + } + } + execSync(`sleep ${pollIntervalMs / 1000}`); + } + + throw new Error( + `Launchable setup did not complete within ${maxWaitMs / 60_000} minutes. ` + + `Sentinel file ${LAUNCHABLE_SENTINEL} not found.`, + ); +} + function runRemoteTest(scriptPath) { const cmd = [ + `set -o pipefail`, + `source ~/.nvm/nvm.sh 2>/dev/null || true`, `cd ${remoteDir}`, `export npm_config_prefix=$HOME/.local`, `export PATH=$HOME/.local/bin:$PATH`, - `bash ${scriptPath}`, + // Docker socket is chmod 666 by setup script, no sg docker needed. + + `bash ${scriptPath} 2>&1 | tee /tmp/test-output.log`, ].join(" && "); - return sshWithSecrets(cmd, { timeout: 600_000 }); + // Stream test output to CI log AND capture it for assertions + sshEnv(cmd, { timeout: 900_000, stream: true }); + // Retrieve the captured output for assertion checking + return ssh("cat /tmp/test-output.log", { timeout: 30_000 }); } // --- suite ------------------------------------------------------------------ @@ -111,6 +200,8 @@ const hasRequiredVars = REQUIRED_VARS.every((key) => process.env[key]); describe.runIf(hasRequiredVars)("Brev E2E", () => { beforeAll(() => { + const bootstrapStart = Date.now(); + const elapsed = () => `${Math.round((Date.now() - bootstrapStart) / 1000)}s`; // Authenticate with Brev mkdirSync(path.join(homedir(), ".brev"), { recursive: true }); @@ -120,26 +211,398 @@ describe.runIf(hasRequiredVars)("Brev E2E", () => { ); brev("login", "--token", process.env.BREV_API_TOKEN); - // Create instance - brev("create", INSTANCE_NAME, "--cpu", BREV_CPU, "--detached"); - instanceCreated = true; - - // Wait for SSH - try { brev("refresh"); } catch { /* ignore */ } - waitForSsh(); - - // Sync code - const remoteHome = ssh("echo $HOME"); - remoteDir = `${remoteHome}/nemoclaw`; - ssh(`mkdir -p ${remoteDir}`); - execSync( - `rsync -az --delete --exclude node_modules --exclude .git --exclude dist --exclude .venv "${REPO_DIR}/" "${INSTANCE_NAME}:${remoteDir}/"`, - { encoding: "utf-8", timeout: 120_000 }, - ); + // Pre-cleanup: delete any leftover instance with the same name. + // This can happen when a previous run's create succeeded on the backend + // but the CLI got a network error (unexpected EOF) before confirming, + // then the retry/fallback fails with "duplicate workspace". + try { + brev("delete", INSTANCE_NAME); + console.log(`[${elapsed()}] Deleted leftover instance "${INSTANCE_NAME}"`); + } catch { + // Expected — no leftover instance exists + } + + if (USE_LAUNCHABLE) { + // ── Launchable path: pre-baked CI environment ────────────────── + // Uses brev search cpu | brev create with --startup-script. + // The script pre-installs Docker, Node.js, OpenShell CLI, npm deps, + // and pre-pulls Docker images. We just need to rsync branch code and + // run onboard. + // + // brev create (v0.6.322+) accepts --startup-script as a string or + // @filepath — not a URL. So we download the script first. + console.log( + `[${elapsed()}] Creating instance via launchable (brev search cpu | brev create + startup-script)...`, + ); + console.log(`[${elapsed()}] setup-script: ${DEFAULT_SETUP_SCRIPT_PATH}`); + console.log( + `[${elapsed()}] cpu: min ${BREV_MIN_VCPU} vCPU, ${BREV_MIN_RAM} GB RAM, ${BREV_MIN_DISK} GB disk, provider: ${BREV_PROVIDER}`, + ); + + // Resolve the setup script to a local file path. + // Default: repo-local scripts/brev-launchable-ci-cpu.sh (hermetic). + // Override: set LAUNCHABLE_SETUP_SCRIPT to a URL and it gets downloaded. + let setupScriptPath; + if (DEFAULT_SETUP_SCRIPT_PATH.startsWith("http")) { + setupScriptPath = "/tmp/brev-ci-setup.sh"; + execSync(`curl -fsSL -o ${setupScriptPath} "${DEFAULT_SETUP_SCRIPT_PATH}"`, { + encoding: "utf-8", + timeout: 30_000, + }); + console.log(`[${elapsed()}] Setup script downloaded to ${setupScriptPath}`); + } else { + setupScriptPath = DEFAULT_SETUP_SCRIPT_PATH; + console.log(`[${elapsed()}] Using repo-local setup script`); + } + + // brev search cpu | brev create: finds cheapest CPU instance matching + // our specs and creates it with the setup script attached. + // + // The Brev API sometimes returns "unexpected EOF" after the instance + // is actually created server-side. The CLI then falls back to the next + // instance type, which fails with "duplicate workspace". To handle this, + // we catch create failures and check if the instance exists anyway. + try { + execSync( + `brev search cpu --min-vcpu ${BREV_MIN_VCPU} --min-ram ${BREV_MIN_RAM} --min-disk ${BREV_MIN_DISK} --provider ${BREV_PROVIDER} --sort price | ` + + `brev create ${INSTANCE_NAME} --startup-script @${setupScriptPath} --detached`, + { encoding: "utf-8", timeout: 180_000, stdio: ["pipe", "inherit", "inherit"] }, + ); + } catch (createErr) { + console.log( + `[${elapsed()}] brev create exited with error — checking if instance was created anyway...`, + ); + try { + brev("refresh"); + } catch { + /* ignore */ + } + const lsOutput = execSync(`brev ls 2>&1 || true`, { encoding: "utf-8", timeout: 30_000 }); + if (!lsOutput.includes(INSTANCE_NAME)) { + throw new Error( + `brev create failed and instance "${INSTANCE_NAME}" not found in brev ls. ` + + `Original error: ${createErr.message}`, + { cause: createErr }, + ); + } + console.log( + `[${elapsed()}] Instance "${INSTANCE_NAME}" found in brev ls despite create error — proceeding`, + ); + } + instanceCreated = true; + console.log(`[${elapsed()}] brev create returned (instance provisioning in background)`); + + // Wait for SSH + try { + brev("refresh"); + } catch { + /* ignore */ + } + waitForSsh(); + console.log(`[${elapsed()}] SSH is up`); + + // Wait for launchable setup to finish (sentinel file) + console.log(`[${elapsed()}] Waiting for launchable setup to complete...`); + waitForLaunchableReady(); + + // The launchable clones NemoClaw to ~/NemoClaw + const remoteHome = ssh("echo $HOME"); + remoteDir = `${remoteHome}/NemoClaw`; + + // Rsync PR branch code over the launchable's clone + console.log(`[${elapsed()}] Syncing PR branch code over launchable's clone...`); + execSync( + `rsync -az --delete --exclude node_modules --exclude .git --exclude dist --exclude .venv "${REPO_DIR}/" "${INSTANCE_NAME}:${remoteDir}/"`, + { encoding: "utf-8", timeout: 120_000 }, + ); + console.log(`[${elapsed()}] Code synced`); + + // Re-install deps for our branch (most already cached by launchable). + // Use `npm install` instead of `npm ci` because the rsync'd branch code + // may have a package.json/package-lock.json that are slightly out of sync + // (e.g. new transitive deps). npm install is more forgiving and still + // benefits from the launchable's pre-cached node_modules. + console.log(`[${elapsed()}] Running npm install to sync dependencies...`); + ssh( + [ + `set -o pipefail`, + `source ~/.nvm/nvm.sh 2>/dev/null || true`, + `cd ${remoteDir}`, + `npm install --ignore-scripts 2>&1 | tail -5`, + ].join(" && "), + { timeout: 300_000, stream: true }, + ); + console.log(`[${elapsed()}] Dependencies synced`); + + // Rebuild TS plugin for our branch (reinstall plugin deps in case they changed) + console.log(`[${elapsed()}] Building TypeScript plugin...`); + ssh( + `source ~/.nvm/nvm.sh 2>/dev/null || true && cd ${remoteDir}/nemoclaw && npm install && npm run build`, + { + timeout: 120_000, + stream: true, + }, + ); + console.log(`[${elapsed()}] Plugin built`); + + // Install nemoclaw CLI. + // Use `sudo npm link` because Node.js is installed system-wide via + // nodesource (global prefix is /usr), so creating the global symlink + // requires elevated permissions. + console.log(`[${elapsed()}] Installing nemoclaw CLI (npm link)...`); + ssh( + `source ~/.nvm/nvm.sh 2>/dev/null || true && cd ${remoteDir} && sudo npm link && sudo chown -R $(whoami):$(whoami) ${remoteDir}`, + { + timeout: 120_000, + stream: true, + }, + ); + console.log(`[${elapsed()}] nemoclaw CLI linked`); + + // Run onboard in the background. The `nemoclaw onboard` process hangs + // after sandbox creation because `openshell sandbox create` keeps a + // long-lived SSH connection to the sandbox entrypoint, and the dashboard + // port-forward also blocks. We launch it in background, poll for sandbox + // readiness via `openshell sandbox list`, then kill the hung process and + // write the registry file ourselves. + // Launch onboard fully detached. We chmod the docker socket so we don't + // need sg docker (which complicates backgrounding). nohup + /dev/null || true`, { timeout: 10_000 }); + // Launch onboard in background. The SSH command may exit with code 255 + // (SSH error) because background processes keep file descriptors open. + // That's fine — we just need the process to start; we'll poll for + // sandbox readiness separately. + try { + sshEnv( + [ + `source ~/.nvm/nvm.sh 2>/dev/null || true`, + `cd ${remoteDir}`, + `nohup nemoclaw onboard --non-interactive /tmp/nemoclaw-onboard.log 2>&1 & disown`, + `sleep 2`, + `echo "onboard launched"`, + ].join(" && "), + { timeout: 30_000 }, + ); + } catch (bgErr) { + // SSH exit 255 or ETIMEDOUT is expected when backgrounding processes. + // Verify the process actually started by checking the log file. + try { + const check = ssh("test -f /tmp/nemoclaw-onboard.log && echo OK || echo MISSING", { + timeout: 10_000, + }); + if (check.includes("OK")) { + console.log( + `[${elapsed()}] Background launch returned non-zero but log file exists — continuing`, + ); + } else { + throw bgErr; + } + } catch { + throw bgErr; + } + } + console.log(`[${elapsed()}] Onboard launched in background`); + + // Poll until openshell reports the sandbox as Ready (or onboard fails). + // The sandbox step is the slow part (~5-10 min for image build + upload). + const maxOnboardWaitMs = 1_200_000; // 20 min + const onboardPollMs = 15_000; + const onboardStart = Date.now(); + const onboardElapsed = () => `${Math.round((Date.now() - onboardStart) / 1000)}s`; + + while (Date.now() - onboardStart < maxOnboardWaitMs) { + try { + const sandboxList = ssh(`openshell sandbox list 2>/dev/null || true`, { + timeout: 15_000, + }); + if (sandboxList.includes("e2e-test") && sandboxList.includes("Ready")) { + console.log(`[${onboardElapsed()}] Sandbox e2e-test is Ready!`); + break; + } + // Show onboard progress from the log + try { + const tail = ssh( + "tail -2 /tmp/nemoclaw-onboard.log 2>/dev/null || echo '(no log yet)'", + { + timeout: 10_000, + }, + ); + console.log( + `[${onboardElapsed()}] Onboard in progress... ${tail.replace(/\n/g, " | ")}`, + ); + } catch { + /* ignore */ + } + } catch { + console.log(`[${onboardElapsed()}] Poll: SSH command failed, retrying...`); + } + + // Check if onboard failed (process exited and no sandbox) + try { + const session = ssh("cat ~/.nemoclaw/onboard-session.json 2>/dev/null || echo '{}'", { + timeout: 10_000, + }); + const parsed = JSON.parse(session); + if (parsed.status === "failed") { + const failLog = ssh("cat /tmp/nemoclaw-onboard.log 2>/dev/null || echo 'no log'", { + timeout: 10_000, + }); + throw new Error(`Onboard failed: ${parsed.failure || "unknown"}\n${failLog}`); + } + } catch (e) { + if (e.message.startsWith("Onboard failed")) throw e; + /* ignore parse errors */ + } + + execSync(`sleep ${onboardPollMs / 1000}`); + } + + // Verify sandbox is actually ready + const finalList = ssh(`openshell sandbox list 2>/dev/null`, { timeout: 15_000 }); + if (!finalList.includes("e2e-test") || !finalList.includes("Ready")) { + const failLog = ssh("cat /tmp/nemoclaw-onboard.log 2>/dev/null || echo 'no log'", { + timeout: 10_000, + }); + throw new Error(`Sandbox not ready after ${maxOnboardWaitMs / 60_000} min.\n${failLog}`); + } + + // Kill the hung onboard process tree and write the sandbox registry + // manually. The onboard hangs on the dashboard port-forward step and + // never writes sandboxes.json. + console.log(`[${elapsed()}] Sandbox ready — killing hung onboard and writing registry...`); + // Kill hung onboard processes. pkill may kill the SSH connection itself + // if the pattern matches too broadly, so wrap in try/catch. + try { + ssh( + `pkill -f "nemoclaw onboard" 2>/dev/null; pkill -f "openshell sandbox create" 2>/dev/null; sleep 1; true`, + { timeout: 15_000 }, + ); + } catch { + // SSH exit 255 is expected — pkill may terminate the connection + console.log( + `[${elapsed()}] pkill returned non-zero (expected — SSH connection may have been affected)`, + ); + } + // Write the sandbox registry using printf to avoid heredoc quoting issues over SSH + const registryJson = JSON.stringify( + { + version: 1, + defaultSandbox: "e2e-test", + sandboxes: { + "e2e-test": { + name: "e2e-test", + createdAt: new Date().toISOString(), + model: null, + nimContainer: null, + provider: null, + gpuEnabled: false, + policies: [], + }, + }, + }, + null, + 2, + ); + ssh( + `mkdir -p ~/.nemoclaw && printf '%s' '${shellEscape(registryJson)}' > ~/.nemoclaw/sandboxes.json`, + { timeout: 15_000 }, + ); + console.log(`[${elapsed()}] Registry written, onboard workaround complete`); + } else { + // ── Bare instance path: brev create + brev-setup.sh ──────────── + // Full bootstrap from scratch. Slower but doesn't require a launchable. + console.log(`[${elapsed()}] Creating bare CPU instance via brev search cpu | brev create...`); + console.log( + `[${elapsed()}] min-vcpu: ${BREV_MIN_VCPU}, min-ram: ${BREV_MIN_RAM}GB, disk: ${BREV_MIN_DISK}GB, provider: ${BREV_PROVIDER}`, + ); + try { + execSync( + `brev search cpu --min-vcpu ${BREV_MIN_VCPU} --min-ram ${BREV_MIN_RAM} --min-disk ${BREV_MIN_DISK} --provider ${BREV_PROVIDER} --sort price | ` + + `brev create ${INSTANCE_NAME} --detached`, + { encoding: "utf-8", timeout: 180_000, stdio: ["pipe", "inherit", "inherit"] }, + ); + } catch (createErr) { + console.log( + `[${elapsed()}] brev create exited with error — checking if instance was created anyway...`, + ); + try { + brev("refresh"); + } catch { + /* ignore */ + } + const lsOutput = execSync(`brev ls 2>&1 || true`, { encoding: "utf-8", timeout: 30_000 }); + if (!lsOutput.includes(INSTANCE_NAME)) { + throw new Error( + `brev create failed and instance "${INSTANCE_NAME}" not found in brev ls. ` + + `Original error: ${createErr.message}`, + { cause: createErr }, + ); + } + console.log( + `[${elapsed()}] Instance "${INSTANCE_NAME}" found in brev ls despite create error — proceeding`, + ); + } + instanceCreated = true; + console.log(`[${elapsed()}] brev create returned (instance provisioning in background)`); + + // Wait for SSH + try { + brev("refresh"); + } catch { + /* ignore */ + } + waitForSsh(); + console.log(`[${elapsed()}] SSH is up`); + + // Sync code + const remoteHome = ssh("echo $HOME"); + remoteDir = `${remoteHome}/nemoclaw`; + ssh(`mkdir -p ${remoteDir}`); + execSync( + `rsync -az --delete --exclude node_modules --exclude .git --exclude dist --exclude .venv "${REPO_DIR}/" "${INSTANCE_NAME}:${remoteDir}/"`, + { encoding: "utf-8", timeout: 120_000 }, + ); + console.log(`[${elapsed()}] Code synced`); + + // Bootstrap VM — stream output to CI log so we can see progress + console.log(`[${elapsed()}] Running brev-setup.sh (bootstrap)...`); + sshEnv(`cd ${remoteDir} && SKIP_VLLM=1 bash scripts/brev-setup.sh`, { + timeout: 2_400_000, + stream: true, + }); + console.log(`[${elapsed()}] Bootstrap complete`); + + // Verify the CLI installed by brev-setup.sh is visible + console.log(`[${elapsed()}] Verifying nemoclaw CLI...`); + ssh( + [ + `export npm_config_prefix=$HOME/.local`, + `export PATH=$HOME/.local/bin:$PATH`, + `which nemoclaw && nemoclaw --version`, + ].join(" && "), + { timeout: 120_000 }, + ); + console.log(`[${elapsed()}] nemoclaw CLI verified`); + } + + // Verify sandbox registry (common to both paths) + console.log(`[${elapsed()}] Verifying sandbox registry...`); + const registry = JSON.parse(ssh(`cat ~/.nemoclaw/sandboxes.json`, { timeout: 10_000 })); + expect(registry.defaultSandbox).toBe("e2e-test"); + expect(registry.sandboxes).toHaveProperty("e2e-test"); + const sandbox = registry.sandboxes["e2e-test"]; + expect(sandbox).toMatchObject({ + name: "e2e-test", + gpuEnabled: false, + policies: [], + }); + console.log(`[${elapsed()}] Sandbox registry verified`); - // Bootstrap VM - sshWithSecrets(`cd ${remoteDir} && bash scripts/brev-setup.sh`, { timeout: 900_000 }); - }, 1_200_000); // 20 min — instance creation + bootstrap can be slow + console.log(`[${elapsed()}] beforeAll complete — total bootstrap time: ${elapsed()}`); + }, 2_700_000); // 45 min afterAll(() => { if (!instanceCreated) return; @@ -156,14 +619,18 @@ describe.runIf(hasRequiredVars)("Brev E2E", () => { } }); - it.runIf(TEST_SUITE === "full" || TEST_SUITE === "all")( + // NOTE: The full E2E test runs install.sh --non-interactive which destroys and + // rebuilds the sandbox from scratch. It cannot run alongside the security tests + // (credential-sanitization, telegram-injection) which depend on the sandbox + // that beforeAll already created. Run it only when TEST_SUITE=full. + it.runIf(TEST_SUITE === "full")( "full E2E suite passes on remote VM", () => { const output = runRemoteTest("test/e2e/test-full-e2e.sh"); expect(output).toContain("PASS"); expect(output).not.toMatch(/FAIL:/); }, - 600_000, + 900_000, ); it.runIf(TEST_SUITE === "credential-sanitization" || TEST_SUITE === "all")( @@ -175,4 +642,14 @@ describe.runIf(hasRequiredVars)("Brev E2E", () => { }, 600_000, ); + + it.runIf(TEST_SUITE === "telegram-injection" || TEST_SUITE === "all")( + "telegram bridge injection suite passes on remote VM", + () => { + const output = runRemoteTest("test/e2e/test-telegram-injection.sh"); + expect(output).toContain("PASS"); + expect(output).not.toMatch(/FAIL:/); + }, + 600_000, + ); }); diff --git a/test/e2e/e2e-cloud-experimental/check-docs.sh b/test/e2e/e2e-cloud-experimental/check-docs.sh index 67888c573..20e3c8178 100755 --- a/test/e2e/e2e-cloud-experimental/check-docs.sh +++ b/test/e2e/e2e-cloud-experimental/check-docs.sh @@ -17,6 +17,8 @@ # Environment: # CHECK_DOC_LINKS_REMOTE If 0, skip http(s) probes for links check. # CHECK_DOC_LINKS_VERBOSE If 1, log each URL during curl (same as --verbose). +# CHECK_DOC_LINKS_IGNORE_EXTRA Comma-separated extra http(s) URLs to skip curling (exact match, #fragment ignored). +# CHECK_DOC_LINKS_IGNORE_URL_REGEX If set, skip curl when the whole URL matches this ERE (bash [[ =~ ]]). # NODE Node for CLI check (default: node). # CURL curl binary (default: curl). @@ -51,7 +53,8 @@ Options: --verbose Log each URL while curling (link check). -h, --help Show this help. -Environment: CHECK_DOC_LINKS_REMOTE, CHECK_DOC_LINKS_VERBOSE, NODE, CURL. +Environment: CHECK_DOC_LINKS_REMOTE, CHECK_DOC_LINKS_VERBOSE, CHECK_DOC_LINKS_IGNORE_EXTRA, + CHECK_DOC_LINKS_IGNORE_URL_REGEX, NODE, CURL. EOF } @@ -217,13 +220,27 @@ collect_default_docs() { fi } +strip_html_comments() { + perl -CS -0pe ' + my $copy = $_; + $copy =~ s/```.*?```//gs; + my $comment = qr//s; + my $stripped = $copy; + $stripped =~ s/$comment//g; + if ($stripped =~ //) { + die "malformed HTML comment"; + } + s/$comment//g; + ' -- "$1" +} + extract_targets() { - perl -CS -ne ' + strip_html_comments "$1" | perl -CS -ne ' if (/^\s*```/) { $in = !$in; next; } next if $in; while (/\!?\[[^\]]*\]\(([^)\s]+)(?:\s+["'"'"'][^)"'"'"']*["'"'"'])?\)/g) { print "$1\n"; } while (/<(https?:[^>\s]+)>/g) { print "$1\n"; } - ' -- "$1" + ' } check_local_ref() { @@ -268,6 +285,54 @@ check_remote_url() { return 0 } +# Normalized form: strip #fragment and trailing slash for ignore-list comparison. +normalize_url_for_ignore_match() { + local u="$1" + u="${u%%\#*}" + u="${u%/}" + printf '%s' "$u" +} + +# Built-in skip list: pages that often fail in CI (bot wall, redirects, or flaky) but are non-critical for doc correctness. +check_docs_default_ignored_urls() { + printf '%s\n' \ + 'https://github.com/NVIDIA/NemoClaw/commits/main' \ + 'https://github.com/NVIDIA/NemoClaw/pulls?q=is%3Apr+is%3Amerged' \ + 'https://github.com/NVIDIA/NemoClaw/pulls?q=is:pr+is:merged' \ + 'https://github.com/openclaw/openclaw/issues/49950' +} + +url_should_skip_remote_probe() { + local url="$1" + local nu ign _re + nu="$(normalize_url_for_ignore_match "$url")" + + while IFS= read -r ign || [[ -n "${ign:-}" ]]; do + [[ -z "${ign:-}" ]] && continue + [[ "$(normalize_url_for_ignore_match "$ign")" == "$nu" ]] && return 0 + done < <(check_docs_default_ignored_urls) + + if [[ -n "${CHECK_DOC_LINKS_IGNORE_EXTRA:-}" ]]; then + local -a _extra_parts=() + local IFS=',' + read -ra _extra_parts <<<"${CHECK_DOC_LINKS_IGNORE_EXTRA}" + unset IFS + for ign in "${_extra_parts[@]}"; do + ign="${ign#"${ign%%[![:space:]]*}"}" + ign="${ign%"${ign##*[![:space:]]}"}" + [[ -z "$ign" ]] && continue + [[ "$(normalize_url_for_ignore_match "$ign")" == "$nu" ]] && return 0 + done + fi + + if [[ -n "${CHECK_DOC_LINKS_IGNORE_URL_REGEX:-}" ]]; then + _re="${CHECK_DOC_LINKS_IGNORE_URL_REGEX}" + [[ "$url" =~ $_re ]] && return 0 + fi + + return 1 +} + run_links_check() { local -a DOC_FILES if [[ ${#EXTRA_FILES[@]} -gt 0 ]]; then @@ -293,6 +358,7 @@ run_links_check() { fi if [[ "$CHECK_DOC_LINKS_REMOTE" != 0 ]]; then log "[links] remote: curl unique http(s) targets (disable: CHECK_DOC_LINKS_REMOTE=0 or --local-only)" + log "[links] remote: built-in skip list for flaky/GitHub pages (override: CHECK_DOC_LINKS_IGNORE_EXTRA, CHECK_DOC_LINKS_IGNORE_URL_REGEX)" else log "[links] remote: skipped (local paths only)" fi @@ -316,6 +382,15 @@ run_links_check() { continue fi local target rc + local _targets_output _targets_err + _targets_err="$(mktemp)" + if ! _targets_output="$(extract_targets "$md" 2>"$_targets_err")"; then + echo "check-docs: [links] malformed HTML comment in $md: $(tr '\n' ' ' <"$_targets_err" | sed 's/[[:space:]]\+/ /g; s/^ //; s/ $//')" >&2 + rm -f "$_targets_err" + failures=1 + continue + fi + rm -f "$_targets_err" while IFS= read -r target || [[ -n "$target" ]]; do [[ -z "$target" ]] && continue set +e @@ -329,7 +404,7 @@ run_links_check() { else failures=1 fi - done < <(extract_targets "$md") + done <<<"$_targets_output" done if [[ "$failures" -ne 0 ]]; then @@ -356,18 +431,33 @@ run_links_check() { if [[ "$CHECK_DOC_LINKS_REMOTE" != 0 ]]; then if [[ -n "$_deduped" ]]; then - log "[links] phase 2/2: curl ${_unique} URL(s) (GET, -L, fail 4xx/5xx)" + local _probe_list="" _skip_count=0 _probe_n=0 + while IFS= read -r url || [[ -n "${url:-}" ]]; do + [[ -z "${url:-}" ]] && continue + if url_should_skip_remote_probe "$url"; then + log "[links] skipped (ignore list): ${url}" + _skip_count=$((_skip_count + 1)) + else + _probe_list+="${url}"$'\n' + fi + done <<<"$_deduped" + _probe_n="$(printf '%s\n' "$_probe_list" | grep -c . || true)" + if [[ "$_skip_count" -gt 0 ]]; then + log "[links] phase 2/2: curl ${_probe_n} URL(s), ${_skip_count} skipped (GET, -L, fail 4xx/5xx)" + else + log "[links] phase 2/2: curl ${_probe_n} URL(s) (GET, -L, fail 4xx/5xx)" + fi _i=0 - while IFS= read -r url || [[ -n "$url" ]]; do - [[ -z "$url" ]] && continue + while IFS= read -r url || [[ -n "${url:-}" ]]; do + [[ -z "${url:-}" ]] && continue _i=$((_i + 1)) if [[ "$VERBOSE" -eq 1 ]]; then - log "[links] [${_i}/${_unique}] ${url}" + log "[links] [${_i}/${_probe_n}] ${url}" fi if ! check_remote_url "$url"; then failures=1 fi - done <<<"$_deduped" + done <<<"$_probe_list" else log "[links] phase 2/2: no http(s) links" fi @@ -384,7 +474,7 @@ run_links_check() { return 1 fi if [[ "$CHECK_DOC_LINKS_REMOTE" != 0 ]] && [[ ${_unique:-0} -gt 0 ]]; then - log "[links] phase 2 OK (${_unique} URL(s))" + log "[links] phase 2 OK (${_unique} unique http(s); probed those not in ignore list)" fi log "[links] summary: ${#DOC_FILES[@]} file(s), local OK$( [[ "$CHECK_DOC_LINKS_REMOTE" != 0 ]] && [[ ${_unique:-0} -gt 0 ]] && printf ', %s remote OK' "${_unique}" diff --git a/test/e2e/e2e-cloud-experimental/cleanup.sh b/test/e2e/e2e-cloud-experimental/cleanup.sh new file mode 100755 index 000000000..59348e377 --- /dev/null +++ b/test/e2e/e2e-cloud-experimental/cleanup.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Shared teardown for e2e-cloud-experimental (extracted from test-e2e-cloud-experimental.sh Phase 0 + Phase 6). +# +# Destroys nemoclaw sandbox, OpenShell sandbox, port 18789 forward, and nemoclaw gateway. +# +# Usage: +# SANDBOX_NAME=my-sbx bash test/e2e/e2e-cloud-experimental/cleanup.sh +# SANDBOX_NAME=my-sbx bash test/e2e/e2e-cloud-experimental/cleanup.sh --verify +# +# Environment: +# SANDBOX_NAME or NEMOCLAW_SANDBOX_NAME — default: e2e-cloud-experimental +# +# Modes: +# (default) — destroy only (best-effort; always exits 0) +# --verify — destroy then assert sandbox is gone from openshell get + nemoclaw list (exits 1 on failure) + +set -uo pipefail + +pass() { printf '\033[32m PASS: %s\033[0m\n' "$1"; } +fail() { printf '\033[31m FAIL: %s\033[0m\n' "$1"; } +skip() { printf '\033[33m SKIP: %s\033[0m\n' "$1"; } +info() { printf '\033[1;34m [info]\033[0m %s\n' "$1"; } + +SANDBOX_NAME="${NEMOCLAW_SANDBOX_NAME:-${SANDBOX_NAME:-e2e-cloud-experimental}}" +VERIFY=0 +if [ "${1:-}" = "--verify" ]; then + VERIFY=1 +fi + +info "e2e-cloud-experimental cleanup: sandbox='${SANDBOX_NAME}' (verify=${VERIFY})" + +if command -v nemoclaw >/dev/null 2>&1; then + nemoclaw "$SANDBOX_NAME" destroy --yes 2>/dev/null || true +fi +if command -v openshell >/dev/null 2>&1; then + openshell sandbox delete "$SANDBOX_NAME" 2>/dev/null || true + openshell forward stop 18789 2>/dev/null || true + openshell gateway destroy -g nemoclaw 2>/dev/null || true +fi + +if [ "$VERIFY" != "1" ]; then + pass "Cleanup destroy complete (no --verify)" + exit 0 +fi + +# ── Post-teardown checks (Phase 6 parity) ── +if command -v openshell >/dev/null 2>&1; then + if openshell sandbox get "$SANDBOX_NAME" >/dev/null 2>&1; then + fail "openshell sandbox get '${SANDBOX_NAME}' still succeeds after cleanup" + exit 1 + fi + pass "openshell: sandbox '${SANDBOX_NAME}' no longer visible to sandbox get" +else + skip "openshell not on PATH — skipped sandbox get check after cleanup" +fi + +if command -v nemoclaw >/dev/null 2>&1; then + set +e + list_out=$(nemoclaw list 2>&1) + list_rc=$? + set -uo pipefail + if [ "$list_rc" -eq 0 ]; then + if echo "$list_out" | grep -Fq " ${SANDBOX_NAME}"; then + fail "nemoclaw list still lists '${SANDBOX_NAME}' after destroy" + exit 1 + fi + pass "nemoclaw list: '${SANDBOX_NAME}' removed from registry" + else + skip "nemoclaw list failed after cleanup — could not verify registry (exit $list_rc)" + fi +else + skip "nemoclaw not on PATH — skipped list check after cleanup" +fi + +pass "Cleanup + verify complete" +exit 0 diff --git a/test/e2e/e2e-cloud-experimental/skip/01-onboard-completion.sh b/test/e2e/e2e-cloud-experimental/skip/01-onboard-completion.sh index 3947d4ded..9aab4bf17 100755 --- a/test/e2e/e2e-cloud-experimental/skip/01-onboard-completion.sh +++ b/test/e2e/e2e-cloud-experimental/skip/01-onboard-completion.sh @@ -16,7 +16,7 @@ # 5) OpenShell sees the sandbox: `openshell sandbox get ` succeeds. # 6) OpenShell list contains the sandbox name. # 7) `openclaw --help`, `openclaw agent --help`, and `openclaw skills list` succeed inside sandbox. -# 8) `openshell inference get` shows provider `nvidia-nim` and the expected model (VDR3 #12). +# 8) `openshell inference get` shows the expected provider (default nvidia-nim; VDR3 #12) and model. # # Requires: # nemoclaw, openshell, openclaw on PATH. @@ -24,15 +24,18 @@ # Env (optional — defaults match test-e2e-cloud-experimental.sh): # SANDBOX_NAME or NEMOCLAW_SANDBOX_NAME (default: e2e-cloud-experimental) # CLOUD_EXPERIMENTAL_MODEL (legacy: SCENARIO_A_MODEL, NEMOCLAW_CLOUD_EXPERIMENTAL_MODEL, NEMOCLAW_SCENARIO_A_MODEL) +# CLOUD_EXPERIMENTAL_INFERENCE_PROVIDER — substring matched in `openshell inference get` (default: nvidia-nim; e.g. ollama-local for local gateways) # # Example: -# bash test/e2e/e2e-cloud-experimental/checks/01-onboard-completion.sh +# bash test/e2e/e2e-cloud-experimental/skip/01-onboard-completion.sh # SANDBOX_NAME=my-box CLOUD_EXPERIMENTAL_MODEL=nvidia/nemotron-3-super-120b-a12b bash ... +# SANDBOX_NAME=test01 CLOUD_EXPERIMENTAL_INFERENCE_PROVIDER=ollama-local CLOUD_EXPERIMENTAL_MODEL=nemotron-3-nano:30b bash ... set -euo pipefail SANDBOX_NAME="${SANDBOX_NAME:-${NEMOCLAW_SANDBOX_NAME:-e2e-cloud-experimental}}" CLOUD_EXPERIMENTAL_MODEL="${CLOUD_EXPERIMENTAL_MODEL:-${SCENARIO_A_MODEL:-${NEMOCLAW_CLOUD_EXPERIMENTAL_MODEL:-${NEMOCLAW_SCENARIO_A_MODEL:-moonshotai/kimi-k2.5}}}}" +CLOUD_EXPERIMENTAL_INFERENCE_PROVIDER="${CLOUD_EXPERIMENTAL_INFERENCE_PROVIDER:-nvidia-nim}" die() { printf '%s\n' "01-onboard-completion: FAIL: $*" >&2 exit 1 @@ -142,8 +145,8 @@ inf_check=$(openshell inference get 2>&1) ig=$? set -e [ "$ig" -eq 0 ] || die "openshell inference get failed: ${inf_check:0:200}" -echo "$inf_check" | grep -qi "nvidia-nim" \ - || die "openshell inference get missing nvidia-nim provider. Output (first 500 chars): ${inf_check:0:500}" +echo "$inf_check" | grep -Fqi "$CLOUD_EXPERIMENTAL_INFERENCE_PROVIDER" \ + || die "openshell inference get missing provider '${CLOUD_EXPERIMENTAL_INFERENCE_PROVIDER}' (set CLOUD_EXPERIMENTAL_INFERENCE_PROVIDER to match Gateway). Output (first 500 chars): ${inf_check:0:500}" if ! echo "$inf_check" | grep -Fq "$CLOUD_EXPERIMENTAL_MODEL"; then die "inference model mismatch: expected substring '${CLOUD_EXPERIMENTAL_MODEL}' (from CLOUD_EXPERIMENTAL_MODEL / NEMOCLAW_CLOUD_EXPERIMENTAL_MODEL) inside 'openshell inference get', but it was not found. If the sandbox was onboarded with another model, export the same id for this check (e.g. NEMOCLAW_CLOUD_EXPERIMENTAL_MODEL=nvidia/nemotron-3-super-120b-a12b). --- openshell inference get (first 800 chars) --- ${inf_check:0:800}" fi diff --git a/test/e2e/e2e-cloud-experimental/skip/04-nemoclaw-openshell-status-parity.sh b/test/e2e/e2e-cloud-experimental/skip/04-nemoclaw-openshell-status-parity.sh index 902e88968..488a8f515 100755 --- a/test/e2e/e2e-cloud-experimental/skip/04-nemoclaw-openshell-status-parity.sh +++ b/test/e2e/e2e-cloud-experimental/skip/04-nemoclaw-openshell-status-parity.sh @@ -8,7 +8,7 @@ # 1) openshell sandbox status --json → .state == running (nemoclaw plugin status path) # 2) else openshell sandbox list → row for name contains Ready (bin/lib/onboard.js isSandboxReady) # Inference model: prefer openshell inference get --json .model; else plain inference get -# (text) must contain nvidia-nim + CLOUD_EXPERIMENTAL_MODEL (same idea as 01-onboard-completion.sh). +# (text) must contain CLOUD_EXPERIMENTAL_INFERENCE_PROVIDER + CLOUD_EXPERIMENTAL_MODEL (same idea as 01-onboard-completion.sh). # nemoclaw list model must match openshell model (JSON or CLOUD_EXPERIMENTAL_MODEL text path). # # Requires: node on PATH (for JSON + list parsing; same shell as post-install suite). @@ -17,6 +17,7 @@ set -euo pipefail SANDBOX_NAME="${SANDBOX_NAME:-${NEMOCLAW_SANDBOX_NAME:-e2e-cloud-experimental}}" CLOUD_EXPERIMENTAL_MODEL="${CLOUD_EXPERIMENTAL_MODEL:-${SCENARIO_A_MODEL:-${NEMOCLAW_CLOUD_EXPERIMENTAL_MODEL:-${NEMOCLAW_SCENARIO_A_MODEL:-moonshotai/kimi-k2.5}}}}" +CLOUD_EXPERIMENTAL_INFERENCE_PROVIDER="${CLOUD_EXPERIMENTAL_INFERENCE_PROVIDER:-nvidia-nim}" export SANDBOX_NAME die() { @@ -81,8 +82,8 @@ if [ -z "$os_model" ]; then inf_rc=$? set -e [ "$inf_rc" -eq 0 ] || die "openshell inference get failed (exit $inf_rc): ${inf_raw:0:240}" - echo "$inf_raw" | grep -qi "nvidia-nim" \ - || die "openshell inference get (text) missing nvidia-nim. Output (first 500 chars): ${inf_raw:0:500}" + echo "$inf_raw" | grep -Fqi "$CLOUD_EXPERIMENTAL_INFERENCE_PROVIDER" \ + || die "openshell inference get (text) missing provider '${CLOUD_EXPERIMENTAL_INFERENCE_PROVIDER}'. Output (first 500 chars): ${inf_raw:0:500}" if ! echo "$inf_raw" | grep -Fq "$CLOUD_EXPERIMENTAL_MODEL"; then die "inference model (text path): expected substring '${CLOUD_EXPERIMENTAL_MODEL}' in 'openshell inference get' (set NEMOCLAW_CLOUD_EXPERIMENTAL_MODEL to match onboarded model). --- output (first 800 chars) --- ${inf_raw:0:800}" fi diff --git a/test/e2e/e2e-cloud-experimental/skip/05-network-policy.sh b/test/e2e/e2e-cloud-experimental/skip/05-network-policy.sh index 10031cd4a..f3e4965d2 100755 --- a/test/e2e/e2e-cloud-experimental/skip/05-network-policy.sh +++ b/test/e2e/e2e-cloud-experimental/skip/05-network-policy.sh @@ -6,13 +6,24 @@ # # A) Host: openshell policy get --full — Version header, network_policies, npm/pypi hosts # (expects NEMOCLAW_POLICY_MODE=custom + npm,pypi presets from suite defaults). -# B) Sandbox over SSH: whitelist HTTPS 2xx/3xx for github / pypi / npm registry; -# blocked probe on E2E_CLOUD_EXPERIMENTAL_EGRESS_BLOCKED_URL (legacy: SCENARIO_A_EGRESS_BLOCKED_URL). +# B) Sandbox over SSH: outlook / Docker Hub (optional curl, commented by default); pypi: venv + pip download; +# npm: npm ping + npm view; huggingface: venv + pip install huggingface_hub + hf|huggingface-cli download +# tiny public config.json (hub + CDN as allowed by preset). Then blocked URL probe. +# Default: curl uses sandbox HTTPS_PROXY / env (matches pip/npm when traffic goes via proxy). +# NEMOCLAW_E2E_CURL_NOPROXY=1: add curl --noproxy '*' (direct TLS; use if CONNECT via proxy returns 403). +# NEMOCLAW_E2E_SKIP_NETWORK_POLICY_HUGGINGFACE=1: skip venv + huggingface_hub + hf download (~5m); still runs pypi + npm + blocked probe. +# +# Vitest (same checks): NEMOCLAW_E2E_NETWORK_POLICY=1 npx vitest run --project network-policy-cli +# +# run_whitelist_egress / curl_exit_hint are optional (outlook/docker curl cases commented out below). +# shellcheck disable=SC2329 set -euo pipefail SANDBOX_NAME="${SANDBOX_NAME:-${NEMOCLAW_SANDBOX_NAME:-e2e-cloud-experimental}}" BLOCKED_URL="${E2E_CLOUD_EXPERIMENTAL_EGRESS_BLOCKED_URL:-${SCENARIO_A_EGRESS_BLOCKED_URL:-https://example.com/}}" +USE_NOPROXY="${NEMOCLAW_E2E_CURL_NOPROXY:-0}" +SKIP_HUGGINGFACE="${NEMOCLAW_E2E_SKIP_NETWORK_POLICY_HUGGINGFACE:-1}" die() { printf '%s\n' "05-network-policy: FAIL: $*" >&2 @@ -25,7 +36,7 @@ curl_exit_hint() { 7) printf '%s' "curl 7 = failed to connect (blocked by policy, down, or wrong port)." ;; 28) printf '%s' "curl 28 = operation timed out (often policy drop or slow path)." ;; 35) printf '%s' "curl 35 = SSL connect error." ;; - 56) printf '%s' "curl 56 = network receive error (TLS reset, proxy/gateway closed connection, etc.)." ;; + 56) printf '%s' "curl 56 = network receive error (TLS reset, proxy CONNECT rejected, etc.)." ;; 60) printf '%s' "curl 60 = peer certificate cannot be authenticated." ;; *) printf '%s' "curl exit $1 — see \`man curl\` EXIT CODES." ;; esac @@ -59,16 +70,25 @@ printf '%s\n' "05-network-policy: policy-yaml OK" # ── B) Egress inside sandbox (SSH) ──────────────────────────────────── ssh_config="$(mktemp)" -wl_log="$(mktemp)" bl_log="$(mktemp)" -trap 'rm -f "$ssh_config" "$wl_log" "$bl_log"' EXIT +trap 'rm -f "$ssh_config" "$bl_log"' EXIT openshell sandbox ssh-config "$SANDBOX_NAME" >"$ssh_config" 2>/dev/null \ || die "egress: openshell sandbox ssh-config failed for '${SANDBOX_NAME}'" TIMEOUT_CMD="" -command -v timeout >/dev/null 2>&1 && TIMEOUT_CMD="timeout 120" -command -v gtimeout >/dev/null 2>&1 && TIMEOUT_CMD="gtimeout 120" +TIMEOUT_CMD_LONG="" +if command -v timeout >/dev/null 2>&1; then + TIMEOUT_CMD="timeout 180" + TIMEOUT_CMD_LONG="timeout 300" +elif command -v gtimeout >/dev/null 2>&1; then + TIMEOUT_CMD="gtimeout 180" + TIMEOUT_CMD_LONG="gtimeout 300" +fi +if [[ -z "$TIMEOUT_CMD" ]]; then + printf '%s\n' "05-network-policy: WARN: no timeout/gtimeout on PATH — each SSH egress step may hang indefinitely (brew install coreutils for gtimeout)." >&2 + TIMEOUT_CMD_LONG="" +fi ssh_host="openshell-${SANDBOX_NAME}" ssh_base=(ssh -F "$ssh_config" @@ -78,55 +98,278 @@ ssh_base=(ssh -F "$ssh_config" -o LogLevel=ERROR ) -set +e -$TIMEOUT_CMD "${ssh_base[@]}" "$ssh_host" bash -s <<'REMOTE' >"$wl_log" 2>&1 +run_whitelist_egress() { + local case_name=$1 + local url=$2 + local wl_log + printf '%s\n' "05-network-policy: egress running: ${case_name} (curl ${url})" + wl_log=$(mktemp) + set +e + $TIMEOUT_CMD "${ssh_base[@]}" "$ssh_host" bash -s -- "$url" "$USE_NOPROXY" <<'REMOTE' >"$wl_log" 2>&1 set -uo pipefail -for url in https://github.com/ https://pypi.org/ https://registry.npmjs.org/; do - efile=$(mktemp) +url=$1 +np=$2 +efile=$(mktemp) +if [ "$np" = "1" ]; then + code=$(curl --noproxy '*' -sS -o /dev/null -w "%{http_code}" --max-time 60 "$url" 2>"$efile") +else code=$(curl -sS -o /dev/null -w "%{http_code}" --max-time 60 "$url" 2>"$efile") - cr=$? - err=$(head -c 800 "$efile" | tr '\n' ' ') - rm -f "$efile" - code=$(printf '%s' "$code" | tr -d '\r' | tail -n 1) - if [ "$cr" -ne 0 ]; then - echo "whitelist: curl transport error for ${url}" - echo " curl_exit=${cr}" - echo " http_code_written=${code:-}" - echo " curl_stderr=${err}" - exit "$cr" - fi - case "$code" in - 2??|3??) ;; - *) - echo "whitelist: unexpected HTTP status for ${url}" - echo " http_code=${code}" - exit 1 - ;; - esac -done +fi +cr=$? +err=$(head -c 800 "$efile" | tr '\n' ' ') +rm -f "$efile" +code=$(printf '%s' "$code" | tr -d '\r' | tail -n 1) +if [ "$cr" -ne 0 ]; then + echo "whitelist: curl transport error for ${url}" + echo " curl_exit=${cr}" + echo " http_code_written=${code:-}" + echo " curl_stderr=${err}" + exit "$cr" +fi +case "$code" in + 2??|3??) ;; + *) + echo "whitelist: unexpected HTTP status for ${url}" + echo " http_code=${code}" + exit 1 + ;; +esac exit 0 REMOTE -wl_rc=$? -set -e -if [ "$wl_rc" -ne 0 ]; then - hint=$(curl_exit_hint "$wl_rc") - die "egress whitelist (github / pypi / npm registry) failed. + local wl_rc=$? + set -e + if [ "$wl_rc" -ne 0 ]; then + hint=$(curl_exit_hint "$wl_rc") + tail_out=$(sed 's/^/ /' "$wl_log" | tail -n 60) + rm -f "$wl_log" + die "egress whitelist case '${case_name}' (${url}) failed. ssh/remote exit: ${wl_rc} hint: ${hint} --- output from sandbox (last 60 lines) --- -$(sed 's/^/ /' "$wl_log" | tail -n 60) +${tail_out} ---" + fi + rm -f "$wl_log" + printf '%s\n' "05-network-policy: egress whitelist OK (${case_name})" +} + +run_whitelist_pypi_via_venv() { + local case_name="pypi" + local wl_log + printf '%s\n' "05-network-policy: egress running: ${case_name} (venv + pip download)" + wl_log=$(mktemp) + set +e + $TIMEOUT_CMD "${ssh_base[@]}" "$ssh_host" bash -s -- "$USE_NOPROXY" <<'REMOTE' >"$wl_log" 2>&1 +set -uo pipefail +np=$1 +VENVD=$(mktemp -d) +PROBE_DL=$(mktemp -d) +cleanup() { rm -rf "$VENVD" "$PROBE_DL"; } +trap cleanup EXIT +if ! command -v python3 >/dev/null 2>&1; then + echo "pypi whitelist: python3 not on PATH" + exit 1 +fi +if ! python3 -m venv "$VENVD" 2>/dev/null; then + echo "pypi whitelist: python3 -m venv failed (need python3-venv / ensure-virtualenv package?)" + exit 1 +fi +# shellcheck disable=SC1091 +. "$VENVD/bin/activate" +if [ "$np" = "1" ]; then + export NO_PROXY='*' + unset HTTPS_PROXY https_proxy HTTP_PROXY http_proxy ALL_PROXY all_proxy || true fi +if ! python -m pip download --no-deps --disable-pip-version-check -d "$PROBE_DL" --timeout 90 idna==3.7; then + echo "pypi whitelist: pip download idna==3.7 from PyPI failed (egress / proxy / policy)" + exit 1 +fi +exit 0 +REMOTE + local wl_rc=$? + set -e + if [ "$wl_rc" -ne 0 ]; then + tail_out=$(sed 's/^/ /' "$wl_log" | tail -n 60) + rm -f "$wl_log" + die "egress whitelist case '${case_name}' (venv + pip download from PyPI) failed. + + ssh/remote exit: ${wl_rc} + + --- output from sandbox (last 60 lines) --- +${tail_out} + ---" + fi + rm -f "$wl_log" + printf '%s\n' "05-network-policy: egress whitelist OK (${case_name})" +} +run_whitelist_npm_via_cli() { + local case_name="npm registry" + local wl_log + printf '%s\n' "05-network-policy: egress running: ${case_name} (npm ping + npm view — lighter than pack/install)" + wl_log=$(mktemp) + set +e + $TIMEOUT_CMD "${ssh_base[@]}" "$ssh_host" bash -s -- "$USE_NOPROXY" <<'REMOTE' >"$wl_log" 2>&1 +set -uo pipefail +np=$1 +WORK=$(mktemp -d) +cleanup() { rm -rf "$WORK"; } +trap cleanup EXIT +cd "$WORK" +export CI=true +export NODE_NO_WARNINGS=1 +export npm_config_progress=false +export npm_config_loglevel=error +export npm_config_fetch_timeout=120000 +export npm_config_fetch_retries=2 +if [ "$np" = "1" ]; then + export NO_PROXY='*' + unset HTTPS_PROXY https_proxy HTTP_PROXY http_proxy ALL_PROXY all_proxy || true +fi +if ! command -v npm >/dev/null 2>&1; then + echo "npm whitelist: npm not on PATH" + exit 1 +fi +# npm ping: minimal registry round-trip (avoids tarball download / long hangs vs npm pack). +echo "npm whitelist: npm ping..." +if ! npm ping --silent 2>/dev/null; then + if ! npm ping; then + echo "npm whitelist: npm ping failed (egress / proxy / policy)" + exit 1 + fi +fi +echo "npm whitelist: npm view is-odd@3.0.1 (metadata)..." +if ! npm view is-odd@3.0.1 version --silent 2>/dev/null; then + if ! npm view is-odd@3.0.1 version; then + echo "npm whitelist: npm view is-odd@3.0.1 failed" + exit 1 + fi +fi +echo "npm whitelist: OK" +exit 0 +REMOTE + local wl_rc=$? + set -e + if [ "$wl_rc" -ne 0 ]; then + tail_out=$(sed 's/^/ /' "$wl_log" | tail -n 60) + rm -f "$wl_log" + die "egress whitelist case '${case_name}' (npm ping / npm view) failed. + + ssh/remote exit: ${wl_rc} + + --- output from sandbox (last 60 lines) --- +${tail_out} + ---" + fi + rm -f "$wl_log" + printf '%s\n' "05-network-policy: egress whitelist OK (${case_name})" +} + +run_whitelist_huggingface_via_cli() { + local case_name="huggingface" + local wl_log + local tcmd="${TIMEOUT_CMD_LONG:-$TIMEOUT_CMD}" + printf '%s\n' "05-network-policy: egress running: ${case_name} (venv + pip huggingface_hub + hf download tiny config.json — up to ~5m)" + wl_log=$(mktemp) + set +e + $tcmd "${ssh_base[@]}" "$ssh_host" bash -s -- "$USE_NOPROXY" <<'REMOTE' >"$wl_log" 2>&1 +set -uo pipefail +np=$1 +VENVD=$(mktemp -d) +DL=$(mktemp -d) +cleanup() { rm -rf "$VENVD" "$DL"; } +trap cleanup EXIT +export HF_HUB_DISABLE_PROGRESS_BARS=1 +export HF_HUB_DISABLE_TELEMETRY=1 +if ! command -v python3 >/dev/null 2>&1; then + echo "huggingface whitelist: python3 not on PATH" + exit 1 +fi +if ! python3 -m venv "$VENVD" 2>/dev/null; then + echo "huggingface whitelist: python3 -m venv failed (need python3-venv?)" + exit 1 +fi +# shellcheck disable=SC1091 +. "$VENVD/bin/activate" +if [ "$np" = "1" ]; then + export NO_PROXY='*' + unset HTTPS_PROXY https_proxy HTTP_PROXY http_proxy ALL_PROXY all_proxy || true +fi +echo "huggingface whitelist: pip install huggingface_hub..." +if ! python -m pip install --disable-pip-version-check --timeout 120 "huggingface_hub>=0.23.0,<1"; then + echo "huggingface whitelist: pip install huggingface_hub failed (PyPI / proxy / policy)" + exit 1 +fi +REPO="hf-internal-testing/tiny-random-bert" +echo "huggingface whitelist: download ${REPO} config.json..." +if command -v hf >/dev/null 2>&1; then + if ! hf download "$REPO" config.json --local-dir "$DL"; then + echo "huggingface whitelist: hf download failed" + exit 1 + fi +elif command -v huggingface-cli >/dev/null 2>&1; then + if ! huggingface-cli download "$REPO" config.json --local-dir "$DL"; then + echo "huggingface whitelist: huggingface-cli download failed" + exit 1 + fi +else + if ! python -c "from huggingface_hub import hf_hub_download; hf_hub_download(repo_id=\"${REPO}\", filename=\"config.json\", local_dir=\"${DL}\")"; then + echo "huggingface whitelist: hf_hub_download (python) failed" + exit 1 + fi +fi +if [ ! -f "$DL/config.json" ]; then + echo "huggingface whitelist: config.json not present under ${DL}" + exit 1 +fi +echo "huggingface whitelist: OK" +exit 0 +REMOTE + local wl_rc=$? + set -e + if [ "$wl_rc" -ne 0 ]; then + tail_out=$(sed 's/^/ /' "$wl_log" | tail -n 60) + rm -f "$wl_log" + die "egress whitelist case '${case_name}' (venv + huggingface_hub + hub download) failed. + + ssh/remote exit: ${wl_rc} + + --- output from sandbox (last 60 lines) --- +${tail_out} + ---" + fi + rm -f "$wl_log" + printf '%s\n' "05-network-policy: egress whitelist OK (${case_name})" +} + +# run_whitelist_egress "outlook" "https://outlook.com/" +# run_whitelist_egress "docker hub" "https://hub.docker.com/" +run_whitelist_pypi_via_venv +run_whitelist_npm_via_cli +if [[ "$SKIP_HUGGINGFACE" == "1" ]]; then + printf '%s\n' "05-network-policy: SKIP huggingface whitelist (NEMOCLAW_E2E_SKIP_NETWORK_POLICY_HUGGINGFACE=1)" +else + run_whitelist_huggingface_via_cli +fi + +printf '%s\n' "05-network-policy: egress running: blocked URL probe (${BLOCKED_URL})" set +e -$TIMEOUT_CMD "${ssh_base[@]}" "$ssh_host" bash -s -- "$BLOCKED_URL" <<'REMOTE' >"$bl_log" 2>&1 +$TIMEOUT_CMD "${ssh_base[@]}" "$ssh_host" bash -s -- "$BLOCKED_URL" "$USE_NOPROXY" <<'REMOTE' >"$bl_log" 2>&1 set -uo pipefail url=$1 -if curl -f -sS -o /dev/null --max-time 30 "$url"; then - echo "expected blocked URL to fail curl, but it succeeded" - exit 1 +np=$2 +if [ "$np" = "1" ]; then + if curl --noproxy '*' -f -sS -o /dev/null --max-time 30 "$url"; then + echo "expected blocked URL to fail curl, but it succeeded" + exit 1 + fi +else + if curl -f -sS -o /dev/null --max-time 30 "$url"; then + echo "expected blocked URL to fail curl, but it succeeded" + exit 1 + fi fi exit 0 REMOTE @@ -140,5 +383,9 @@ $(sed 's/^/ /' "$bl_log" | tail -n 40) ---" fi -printf '%s\n' "05-network-policy: OK (policy-yaml + whitelist + blocked URL)" +if [[ "$SKIP_HUGGINGFACE" == "1" ]]; then + printf '%s\n' "05-network-policy: OK (policy-yaml + pypi + npm + blocked URL; huggingface skipped)" +else + printf '%s\n' "05-network-policy: OK (policy-yaml + pypi + npm + huggingface whitelist + blocked URL)" +fi exit 0 diff --git a/test/e2e/e2e-cloud-experimental/test-port8080-conflict.sh b/test/e2e/e2e-cloud-experimental/test-port8080-conflict.sh index f10392c8d..b35a4d000 100755 --- a/test/e2e/e2e-cloud-experimental/test-port8080-conflict.sh +++ b/test/e2e/e2e-cloud-experimental/test-port8080-conflict.sh @@ -18,12 +18,13 @@ # 2 — skipped (no python3/python to bind 8080) # # Environment (typical): -# NEMOCLAW_SANDBOX_NAME — default: e2e-cloud-experimental -# NEMOCLAW_NON_INTERACTIVE — should be 1 (onboard non-interactive) -# NVIDIA_API_KEY — required if onboard reaches cloud inference (restore path) +# NEMOCLAW_SANDBOX_NAME — default: e2e-cloud-experimental +# NEMOCLAW_NON_INTERACTIVE — should be 1 (onboard non-interactive) +# NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 — required for non-interactive onboard/re-onboard +# NVIDIA_API_KEY — required if onboard reaches cloud inference (restore path) # # Usage: -# NEMOCLAW_NON_INTERACTIVE=1 NVIDIA_API_KEY=nvapi-... bash test/e2e/test-port8080-conflict.sh +# NEMOCLAW_NON_INTERACTIVE=1 NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 NVIDIA_API_KEY=nvapi-... bash test/e2e/e2e-cloud-experimental/test-port8080-conflict.sh set -uo pipefail @@ -74,7 +75,7 @@ PASS "Port 8080 occupied by dummy process (PID ${occupier_pid})" P4_LOG="$(mktemp)" INFO "Running nemoclaw onboard --non-interactive (expect preflight to fail on port 8080)..." set +e -nemoclaw onboard --non-interactive >"$P4_LOG" 2>&1 +NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 nemoclaw onboard --non-interactive >"$P4_LOG" 2>&1 p4_exit=$? set -euo pipefail p4_out="$(cat "$P4_LOG")" @@ -123,7 +124,7 @@ else INFO "Sandbox missing after gateway destroy/recreate — re-onboarding with NEMOCLAW_RECREATE_SANDBOX=1..." P4R_LOG="$(mktemp)" set +e - NEMOCLAW_RECREATE_SANDBOX=1 nemoclaw onboard --non-interactive >"$P4R_LOG" 2>&1 + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 NEMOCLAW_RECREATE_SANDBOX=1 nemoclaw onboard --non-interactive >"$P4R_LOG" 2>&1 p4r_exit=$? set -euo pipefail if [ "$p4r_exit" -ne 0 ]; then diff --git a/test/e2e/test-credential-sanitization.sh b/test/e2e/test-credential-sanitization.sh new file mode 100755 index 000000000..f86aecda0 --- /dev/null +++ b/test/e2e/test-credential-sanitization.sh @@ -0,0 +1,805 @@ +#!/bin/bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Credential Sanitization & Blueprint Digest E2E Tests +# +# Validates that PR #156's fix correctly strips credentials from migration +# bundles and that empty blueprint digests are no longer silently accepted. +# +# Attack surface: +# Before the fix, createSnapshotBundle() copied the entire ~/.openclaw +# directory into the sandbox, including auth-profiles.json with live API +# keys, GitHub PATs, and npm tokens. A compromised agent could read these +# and exfiltrate them. Additionally, blueprint.yaml shipped with digest: "" +# which caused the integrity check to silently pass (JS falsy). +# +# Prerequisites: +# - Docker running +# - NemoClaw installed and sandbox running (test-full-e2e.sh Phase 0-3) +# - NVIDIA_API_KEY set +# - openshell on PATH +# +# Environment variables: +# NEMOCLAW_SANDBOX_NAME — sandbox name (default: e2e-test) +# NVIDIA_API_KEY — required +# +# Usage: +# NEMOCLAW_NON_INTERACTIVE=1 NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 NVIDIA_API_KEY=nvapi-... bash test/e2e/test-credential-sanitization.sh +# +# See: https://github.com/NVIDIA/NemoClaw/pull/156 + +set -uo pipefail + +PASS=0 +FAIL=0 +SKIP=0 +TOTAL=0 + +pass() { + ((PASS++)) + ((TOTAL++)) + printf '\033[32m PASS: %s\033[0m\n' "$1" +} +fail() { + ((FAIL++)) + ((TOTAL++)) + printf '\033[31m FAIL: %s\033[0m\n' "$1" +} +skip() { + ((SKIP++)) + ((TOTAL++)) + printf '\033[33m SKIP: %s\033[0m\n' "$1" +} +section() { + echo "" + printf '\033[1;36m=== %s ===\033[0m\n' "$1" +} +info() { printf '\033[1;34m [info]\033[0m %s\n' "$1"; } + +# Determine repo root +if [ -d /workspace ] && [ -f /workspace/install.sh ]; then + REPO="/workspace" +elif [ -f "$(cd "$(dirname "$0")/../.." && pwd)/install.sh" ]; then + REPO="$(cd "$(dirname "$0")/../.." && pwd)" +else + echo "ERROR: Cannot find repo root." + exit 1 +fi + +SANDBOX_NAME="${NEMOCLAW_SANDBOX_NAME:-e2e-test}" + +# Run a command inside the sandbox and capture output. +# Returns __PROBE_FAILED__ and exit 1 if SSH setup or execution fails, +# so callers can distinguish "no output" from "probe never ran". +sandbox_exec() { + local cmd="$1" + local ssh_config + ssh_config="$(mktemp)" + if ! openshell sandbox ssh-config "$SANDBOX_NAME" >"$ssh_config" 2>/dev/null; then + rm -f "$ssh_config" + echo "__PROBE_FAILED__" + return 1 + fi + + local result + local rc=0 + result=$(timeout 60 ssh -F "$ssh_config" \ + -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null \ + -o ConnectTimeout=10 \ + -o LogLevel=ERROR \ + "openshell-${SANDBOX_NAME}" \ + "$cmd" \ + 2>&1) || rc=$? + + rm -f "$ssh_config" + if [ "$rc" -ne 0 ] && [ -z "$result" ]; then + echo "__PROBE_FAILED__" + return 1 + fi + echo "$result" +} + +# ══════════════════════════════════════════════════════════════════ +# Phase 0: Prerequisites +# ══════════════════════════════════════════════════════════════════ +section "Phase 0: Prerequisites" + +if [ -z "${NVIDIA_API_KEY:-}" ]; then + fail "NVIDIA_API_KEY not set" + exit 1 +fi +pass "NVIDIA_API_KEY is set" + +if ! command -v openshell >/dev/null 2>&1; then + fail "openshell not found on PATH" + exit 1 +fi +pass "openshell found" + +if ! command -v nemoclaw >/dev/null 2>&1; then + fail "nemoclaw not found on PATH" + exit 1 +fi +pass "nemoclaw found" + +if ! command -v node >/dev/null 2>&1; then + fail "node not found on PATH" + exit 1 +fi +pass "node found" + +# Verify sandbox is running +# shellcheck disable=SC2034 # status_output captures stderr for diagnostics on failure +if status_output=$(nemoclaw "$SANDBOX_NAME" status 2>&1); then + pass "Sandbox '${SANDBOX_NAME}' is running" +else + fail "Sandbox '${SANDBOX_NAME}' not running — run test-full-e2e.sh first" + exit 1 +fi + +# ══════════════════════════════════════════════════════════════════ +# Phase 1: Credential Stripping from Migration Bundles +# +# We create a mock ~/.openclaw directory with known fake credentials, +# then run the sanitization functions and verify the output. +# ══════════════════════════════════════════════════════════════════ +section "Phase 1: Credential Stripping (Unit-Level on Real Stack)" + +# Deliberately non-matching fake tokens that will NOT trigger secret scanners. +FAKE_NVIDIA_KEY="test-fake-nvidia-key-0000000000000000" +FAKE_GITHUB_TOKEN="test-fake-github-token-1111111111111111" +FAKE_NPM_TOKEN="test-fake-npm-token-2222222222222222" +FAKE_GATEWAY_TOKEN="test-fake-gateway-token-333333333333" + +# Create a temp directory simulating the state that would be migrated +MOCK_DIR=$(mktemp -d /tmp/nemoclaw-cred-test-XXXXXX) +MOCK_STATE="$MOCK_DIR/.openclaw" +mkdir -p "$MOCK_STATE" + +# Create openclaw.json with credential fields +cat >"$MOCK_STATE/openclaw.json" <"$AUTH_DIR/auth-profiles.json" <"$MOCK_STATE/workspace/project.md" + +# Copy to simulate bundle +BUNDLE_DIR="$MOCK_DIR/bundle/openclaw" +mkdir -p "$BUNDLE_DIR" +cp -r "$MOCK_STATE"/* "$BUNDLE_DIR/" 2>/dev/null || true +cp -r "$MOCK_STATE"/.[!.]* "$BUNDLE_DIR/" 2>/dev/null || true +# Actually copy the directory contents properly +rm -rf "$BUNDLE_DIR" +cp -r "$MOCK_STATE" "$BUNDLE_DIR" + +# Run the sanitization logic via node (mirrors production sanitizeCredentialsInBundle) +info "C1-C5: Running credential sanitization on mock bundle..." +sanitize_result=$(cd "$REPO" && node -e " +const fs = require('fs'); +const path = require('path'); + +// --- Credential field detection (mirrors migration-state.ts) --- +const CREDENTIAL_FIELDS = new Set([ + 'apiKey', 'api_key', 'token', 'secret', 'password', 'resolvedKey', +]); +const CREDENTIAL_FIELD_PATTERN = + /(?:access|refresh|client|bearer|auth|api|private|public|signing|session)(?:Token|Key|Secret|Password)$/; + +function isCredentialField(key) { + return CREDENTIAL_FIELDS.has(key) || CREDENTIAL_FIELD_PATTERN.test(key); +} + +function stripCredentials(obj) { + if (obj === null || obj === undefined) return obj; + if (typeof obj !== 'object') return obj; + if (Array.isArray(obj)) return obj.map(stripCredentials); + const result = {}; + for (const [key, value] of Object.entries(obj)) { + if (isCredentialField(key)) { + result[key] = '[STRIPPED_BY_MIGRATION]'; + } else { + result[key] = stripCredentials(value); + } + } + return result; +} + +function walkAndRemoveFile(dirPath, targetName) { + let entries; + try { entries = fs.readdirSync(dirPath); } catch { return; } + for (const entry of entries) { + const fullPath = path.join(dirPath, entry); + try { + const stat = fs.lstatSync(fullPath); + if (stat.isSymbolicLink()) continue; + if (stat.isDirectory()) { + walkAndRemoveFile(fullPath, targetName); + } else if (entry === targetName) { + fs.rmSync(fullPath, { force: true }); + } + } catch {} + } +} + +const bundleDir = '$BUNDLE_DIR'; + +// 1. Remove auth-profiles.json +const agentsDir = path.join(bundleDir, 'agents'); +if (fs.existsSync(agentsDir)) { + walkAndRemoveFile(agentsDir, 'auth-profiles.json'); +} + +// 2. Strip credential fields from openclaw.json +const configPath = path.join(bundleDir, 'openclaw.json'); +if (fs.existsSync(configPath)) { + const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const sanitized = stripCredentials(config); + fs.writeFileSync(configPath, JSON.stringify(sanitized, null, 2)); +} + +console.log('SANITIZED'); +" 2>&1) + +if echo "$sanitize_result" | grep -q "SANITIZED"; then + pass "Sanitization ran successfully" +else + fail "Sanitization script failed: ${sanitize_result:0:200}" +fi + +# C1: No nvapi- strings in the entire bundle +info "C1: Checking for API key leaks in bundle..." +nvapi_hits=$(grep -r "test-fake-nvidia-key" "$BUNDLE_DIR" 2>/dev/null || true) +if [ -z "$nvapi_hits" ]; then + pass "C1: No fake NVIDIA key found in bundle" +else + fail "C1: Fake NVIDIA key found in bundle: ${nvapi_hits:0:200}" +fi + +# Also check for the other fake tokens +github_hits=$(grep -r "test-fake-github-token" "$BUNDLE_DIR" 2>/dev/null || true) +npm_hits=$(grep -r "test-fake-npm-token" "$BUNDLE_DIR" 2>/dev/null || true) +gateway_hits=$(grep -r "test-fake-gateway-token" "$BUNDLE_DIR" 2>/dev/null || true) + +if [ -z "$github_hits" ] && [ -z "$npm_hits" ] && [ -z "$gateway_hits" ]; then + pass "C1b: No fake GitHub/npm/gateway tokens found in bundle" +else + fail "C1b: Fake tokens found — github: ${github_hits:0:80}, npm: ${npm_hits:0:80}, gateway: ${gateway_hits:0:80}" +fi + +# C2: auth-profiles.json must not exist anywhere in the bundle +info "C2: Checking for auth-profiles.json..." +auth_files=$(find "$BUNDLE_DIR" -name "auth-profiles.json" 2>/dev/null || true) +if [ -z "$auth_files" ]; then + pass "C2: auth-profiles.json deleted from bundle" +else + fail "C2: auth-profiles.json still exists: $auth_files" +fi + +# C3: openclaw.json credential fields must be [STRIPPED_BY_MIGRATION] +info "C3: Checking credential field sanitization in openclaw.json..." +config_content=$(cat "$BUNDLE_DIR/openclaw.json" 2>/dev/null || echo "{}") + +nvidia_apikey=$(echo "$config_content" | python3 -c " +import json, sys +config = json.load(sys.stdin) +print(config.get('nvidia', {}).get('apiKey', 'MISSING')) +" 2>/dev/null || echo "PARSE_ERROR") + +gateway_token=$(echo "$config_content" | python3 -c " +import json, sys +config = json.load(sys.stdin) +print(config.get('gateway', {}).get('auth', {}).get('token', 'MISSING')) +" 2>/dev/null || echo "PARSE_ERROR") + +if [ "$nvidia_apikey" = "[STRIPPED_BY_MIGRATION]" ]; then + pass "C3a: nvidia.apiKey replaced with sentinel" +else + fail "C3a: nvidia.apiKey not sanitized (got: $nvidia_apikey)" +fi + +if [ "$gateway_token" = "[STRIPPED_BY_MIGRATION]" ]; then + pass "C3b: gateway.auth.token replaced with sentinel" +else + fail "C3b: gateway.auth.token not sanitized (got: $gateway_token)" +fi + +# C4: Non-credential fields must be preserved +info "C4: Checking non-credential field preservation..." +model_primary=$(echo "$config_content" | python3 -c " +import json, sys +config = json.load(sys.stdin) +print(config.get('agents', {}).get('defaults', {}).get('model', {}).get('primary', 'MISSING')) +" 2>/dev/null || echo "PARSE_ERROR") + +gateway_mode=$(echo "$config_content" | python3 -c " +import json, sys +config = json.load(sys.stdin) +print(config.get('gateway', {}).get('mode', 'MISSING')) +" 2>/dev/null || echo "PARSE_ERROR") + +if [ "$model_primary" = "nvidia/nemotron-3-super-120b-a12b" ]; then + pass "C4a: agents.defaults.model.primary preserved" +else + fail "C4a: agents.defaults.model.primary corrupted (got: $model_primary)" +fi + +if [ "$gateway_mode" = "local" ]; then + pass "C4b: gateway.mode preserved" +else + fail "C4b: gateway.mode corrupted (got: $gateway_mode)" +fi + +# C5: Workspace files must be intact +info "C5: Checking workspace file integrity..." +if [ -f "$BUNDLE_DIR/workspace/project.md" ]; then + project_content=$(cat "$BUNDLE_DIR/workspace/project.md") + if [ "$project_content" = "# My Project" ]; then + pass "C5: workspace/project.md intact" + else + fail "C5: workspace/project.md content changed" + fi +else + fail "C5: workspace/project.md missing from bundle" +fi + +# Cleanup mock directory +rm -rf "$MOCK_DIR" + +# ══════════════════════════════════════════════════════════════════ +# Phase 2: Runtime Sandbox Credential Check +# +# Verify that credentials are NOT accessible from inside the running +# sandbox. This tests the end-to-end flow: migrate → sandbox start → +# agent cannot read credentials from filesystem. +# ══════════════════════════════════════════════════════════════════ +section "Phase 2: Runtime Sandbox Credential Check" + +# C6: auth-profiles.json must not exist inside the sandbox +info "C6: Checking for auth-profiles.json inside sandbox..." +c6_result=$(sandbox_exec "find /sandbox -name 'auth-profiles.json' 2>/dev/null | head -5") + +if [ "$c6_result" = "__PROBE_FAILED__" ]; then + fail "C6: Sandbox probe failed — SSH did not execute; cannot verify auth-profiles.json absence" +elif [ -z "$c6_result" ]; then + pass "C6: No auth-profiles.json found inside sandbox" +else + fail "C6: auth-profiles.json found inside sandbox: $c6_result" +fi + +# C7: No real secret patterns in sandbox config files +info "C7: Checking for secret patterns in sandbox config..." + +# Search for real API key patterns (not our test fakes). +# Exclude policy preset files (e.g. npm.yaml contains "npm_yarn" rule names, not secrets). +c7_nvapi=$(sandbox_exec "grep -r 'nvapi-' /sandbox/.openclaw/ /sandbox/.nemoclaw/ 2>/dev/null | grep -v 'STRIPPED' | grep -v '/policies/' | head -5" || true) +c7_ghp=$(sandbox_exec "grep -r 'ghp_' /sandbox/.openclaw/ /sandbox/.nemoclaw/ 2>/dev/null | grep -v 'STRIPPED' | grep -v '/policies/' | head -5" || true) +c7_npm=$(sandbox_exec "grep -r 'npm_' /sandbox/.openclaw/ /sandbox/.nemoclaw/ 2>/dev/null | grep -v 'STRIPPED' | grep -v '/policies/' | head -5" || true) + +if [ "$c7_nvapi" = "__PROBE_FAILED__" ] || [ "$c7_ghp" = "__PROBE_FAILED__" ] || [ "$c7_npm" = "__PROBE_FAILED__" ]; then + fail "C7: Sandbox probe failed — SSH did not execute; cannot verify secret absence" +elif [ -z "$c7_nvapi" ] && [ -z "$c7_ghp" ] && [ -z "$c7_npm" ]; then + pass "C7: No secret patterns (nvapi-, ghp_, npm_) found in sandbox config" +else + fail "C7: Secret patterns found in sandbox — nvapi: ${c7_nvapi:0:100}, ghp: ${c7_ghp:0:100}, npm: ${c7_npm:0:100}" +fi + +# ══════════════════════════════════════════════════════════════════ +# Phase 3: Symlink Safety +# ══════════════════════════════════════════════════════════════════ +section "Phase 3: Symlink Safety" + +# C8: Symlinked auth-profiles.json must NOT delete the target file +info "C8: Testing symlink traversal protection..." + +SYMLINK_DIR=$(mktemp -d /tmp/nemoclaw-symlink-test-XXXXXX) +OUTSIDE_DIR="$SYMLINK_DIR/outside" +BUNDLE_SYM_DIR="$SYMLINK_DIR/bundle/agents" +mkdir -p "$OUTSIDE_DIR" "$BUNDLE_SYM_DIR" + +# Create a real file outside the bundle +echo '{"shouldNotBeDeleted": true}' >"$OUTSIDE_DIR/auth-profiles.json" + +# Create a symlink inside the bundle pointing to the outside file +ln -s "$OUTSIDE_DIR/auth-profiles.json" "$BUNDLE_SYM_DIR/auth-profiles.json" + +# Run walkAndRemoveFile — it should skip symlinks +c8_result=$(cd "$REPO" && node -e " +const fs = require('fs'); +const path = require('path'); + +function walkAndRemoveFile(dirPath, targetName) { + let entries; + try { entries = fs.readdirSync(dirPath); } catch { return; } + for (const entry of entries) { + const fullPath = path.join(dirPath, entry); + try { + const stat = fs.lstatSync(fullPath); + if (stat.isSymbolicLink()) continue; // SKIP SYMLINKS + if (stat.isDirectory()) { + walkAndRemoveFile(fullPath, targetName); + } else if (entry === targetName) { + fs.rmSync(fullPath, { force: true }); + } + } catch {} + } +} + +walkAndRemoveFile('$BUNDLE_SYM_DIR', 'auth-profiles.json'); + +// Check if the outside file still exists +if (fs.existsSync('$OUTSIDE_DIR/auth-profiles.json')) { + console.log('SAFE'); +} else { + console.log('EXPLOITED'); +} +" 2>&1) + +if echo "$c8_result" | grep -q "SAFE"; then + pass "C8: Symlink traversal blocked — outside file preserved" +else + fail "C8: Symlink traversal — outside file was DELETED through symlink!" +fi + +rm -rf "$SYMLINK_DIR" + +# ══════════════════════════════════════════════════════════════════ +# Phase 4: Blueprint Digest Verification +# ══════════════════════════════════════════════════════════════════ +section "Phase 4: Blueprint Digest Verification" + +# C9: Empty digest string must be treated as a FAILURE +info "C9: Testing empty digest rejection..." + +c9_result=$(cd "$REPO" && node -e " +// Simulate the FIXED verifyBlueprintDigest behavior: +// Empty/missing digest must be a hard failure, not a silent pass. + +function verifyBlueprintDigest_FIXED(manifest) { + if (!manifest.digest || manifest.digest.trim() === '') { + return { valid: false, reason: 'Blueprint has no digest — verification required' }; + } + // In real code, this would compute and compare the hash + return { valid: true }; +} + +// The bug: digest: '' is falsy in JS, so the OLD code did: +// if (manifest.digest && ...) — which skipped verification entirely +function verifyBlueprintDigest_VULNERABLE(manifest) { + if (manifest.digest && manifest.digest !== 'WRONG') { + return { valid: true }; + } + if (!manifest.digest) { + // This is the bug: empty string silently passes + return { valid: true, reason: 'no digest to verify' }; + } + return { valid: false, reason: 'digest mismatch' }; +} + +// Test the FIXED version +const result = verifyBlueprintDigest_FIXED({ digest: '' }); +if (!result.valid) { + console.log('REJECTED_EMPTY'); +} else { + console.log('ACCEPTED_EMPTY'); +} + +// Also test with undefined/null +const result2 = verifyBlueprintDigest_FIXED({ digest: undefined }); +if (!result2.valid) { + console.log('REJECTED_UNDEFINED'); +} else { + console.log('ACCEPTED_UNDEFINED'); +} +" 2>&1) + +if echo "$c9_result" | grep -q "REJECTED_EMPTY"; then + pass "C9a: Empty digest string correctly rejected" +else + fail "C9a: Empty digest string was ACCEPTED — bypass still possible!" +fi + +if echo "$c9_result" | grep -q "REJECTED_UNDEFINED"; then + pass "C9b: Undefined digest correctly rejected" +else + fail "C9b: Undefined digest was ACCEPTED — bypass still possible!" +fi + +# C10: Wrong digest must fail verification +info "C10: Testing wrong digest rejection..." + +c10_result=$(cd "$REPO" && node -e " +const crypto = require('crypto'); + +function verifyDigest(manifest, blueprintContent) { + if (!manifest.digest || manifest.digest.trim() === '') { + return { valid: false, reason: 'no digest' }; + } + const computed = crypto.createHash('sha256').update(blueprintContent).digest('hex'); + if (manifest.digest !== computed) { + return { valid: false, reason: 'digest mismatch: expected ' + manifest.digest + ', got ' + computed }; + } + return { valid: true }; +} + +const content = 'blueprint content here'; +const wrongDigest = 'deadbeef0000000000000000000000000000000000000000000000000000dead'; +const result = verifyDigest({ digest: wrongDigest }, content); +console.log(result.valid ? 'ACCEPTED_WRONG' : 'REJECTED_WRONG'); +" 2>&1) + +if echo "$c10_result" | grep -q "REJECTED_WRONG"; then + pass "C10: Wrong digest correctly rejected" +else + fail "C10: Wrong digest was ACCEPTED — verification broken!" +fi + +# C11: Correct digest must pass +info "C11: Testing correct digest acceptance..." + +c11_result=$(cd "$REPO" && node -e " +const crypto = require('crypto'); + +function verifyDigest(manifest, blueprintContent) { + if (!manifest.digest || manifest.digest.trim() === '') { + return { valid: false, reason: 'no digest' }; + } + const computed = crypto.createHash('sha256').update(blueprintContent).digest('hex'); + if (manifest.digest !== computed) { + return { valid: false, reason: 'digest mismatch' }; + } + return { valid: true }; +} + +const content = 'blueprint content here'; +const correctDigest = crypto.createHash('sha256').update(content).digest('hex'); +const result = verifyDigest({ digest: correctDigest }, content); +console.log(result.valid ? 'ACCEPTED_CORRECT' : 'REJECTED_CORRECT'); +" 2>&1) + +if echo "$c11_result" | grep -q "ACCEPTED_CORRECT"; then + pass "C11: Correct digest correctly accepted" +else + fail "C11: Correct digest was REJECTED — false negative!" +fi + +# ══════════════════════════════════════════════════════════════════ +# Phase 5: Pattern-Based Credential Field Detection +# ══════════════════════════════════════════════════════════════════ +section "Phase 5: Pattern-Based Credential Detection" + +# C12: Pattern-matched credential fields must be stripped +info "C12: Testing pattern-based credential field stripping..." + +c12_result=$(cd "$REPO" && node -e " +const CREDENTIAL_FIELDS = new Set([ + 'apiKey', 'api_key', 'token', 'secret', 'password', 'resolvedKey', +]); +const CREDENTIAL_FIELD_PATTERN = + /(?:access|refresh|client|bearer|auth|api|private|public|signing|session)(?:Token|Key|Secret|Password)$/; + +function isCredentialField(key) { + return CREDENTIAL_FIELDS.has(key) || CREDENTIAL_FIELD_PATTERN.test(key); +} + +function stripCredentials(obj) { + if (obj === null || obj === undefined) return obj; + if (typeof obj !== 'object') return obj; + if (Array.isArray(obj)) return obj.map(stripCredentials); + const result = {}; + for (const [key, value] of Object.entries(obj)) { + if (isCredentialField(key)) { + result[key] = '[STRIPPED_BY_MIGRATION]'; + } else { + result[key] = stripCredentials(value); + } + } + return result; +} + +const config = { + provider: { + accessToken: 'test-access-token-value', + refreshToken: 'test-refresh-token-value', + privateKey: 'test-private-key-value', + clientSecret: 'test-client-secret-value', + signingKey: 'test-signing-key-value', + bearerToken: 'test-bearer-token-value', + sessionToken: 'test-session-token-value', + authKey: 'test-auth-key-value', + } +}; + +const sanitized = stripCredentials(config); +const allStripped = Object.values(sanitized.provider).every(v => v === '[STRIPPED_BY_MIGRATION]'); +console.log(allStripped ? 'ALL_STRIPPED' : 'SOME_LEAKED'); + +// Print any that weren't stripped for debugging +for (const [k, v] of Object.entries(sanitized.provider)) { + if (v !== '[STRIPPED_BY_MIGRATION]') { + console.log('LEAKED: ' + k + ' = ' + v); + } +} +" 2>&1) + +if echo "$c12_result" | grep -q "ALL_STRIPPED"; then + pass "C12: All pattern-matched credential fields stripped" +else + fail "C12: Some credential fields NOT stripped: ${c12_result}" +fi + +# C13: Non-credential fields with partial keyword overlap must be preserved +info "C13: Testing non-credential field preservation..." + +c13_result=$(cd "$REPO" && node -e " +const CREDENTIAL_FIELDS = new Set([ + 'apiKey', 'api_key', 'token', 'secret', 'password', 'resolvedKey', +]); +const CREDENTIAL_FIELD_PATTERN = + /(?:access|refresh|client|bearer|auth|api|private|public|signing|session)(?:Token|Key|Secret|Password)$/; + +function isCredentialField(key) { + return CREDENTIAL_FIELDS.has(key) || CREDENTIAL_FIELD_PATTERN.test(key); +} + +function stripCredentials(obj) { + if (obj === null || obj === undefined) return obj; + if (typeof obj !== 'object') return obj; + if (Array.isArray(obj)) return obj.map(stripCredentials); + const result = {}; + for (const [key, value] of Object.entries(obj)) { + if (isCredentialField(key)) { + result[key] = '[STRIPPED_BY_MIGRATION]'; + } else { + result[key] = stripCredentials(value); + } + } + return result; +} + +const config = { + displayName: 'should-be-preserved', + sortKey: 'should-also-be-preserved', + modelName: 'nvidia/nemotron-3-super-120b-a12b', + keyRef: { source: 'env', id: 'NVIDIA_API_KEY' }, + description: 'A secret garden (but not a real secret)', + tokenizer: 'sentencepiece', + endpoint: 'https://api.nvidia.com/v1', + sessionId: 'abc-123', + accessLevel: 'admin', + publicUrl: 'https://example.com', +}; + +const sanitized = stripCredentials(config); +const results = []; + +// These should ALL be preserved (not stripped) +const expected = { + displayName: 'should-be-preserved', + sortKey: 'should-also-be-preserved', + modelName: 'nvidia/nemotron-3-super-120b-a12b', + description: 'A secret garden (but not a real secret)', + tokenizer: 'sentencepiece', + endpoint: 'https://api.nvidia.com/v1', + sessionId: 'abc-123', + accessLevel: 'admin', + publicUrl: 'https://example.com', +}; + +let allPreserved = true; +for (const [key, expectedVal] of Object.entries(expected)) { + if (sanitized[key] !== expectedVal) { + console.log('CORRUPTED: ' + key + ' = ' + JSON.stringify(sanitized[key]) + ' (expected: ' + expectedVal + ')'); + allPreserved = false; + } +} + +// keyRef is an object — check it's preserved structurally +if (JSON.stringify(sanitized.keyRef) !== JSON.stringify({ source: 'env', id: 'NVIDIA_API_KEY' })) { + console.log('CORRUPTED: keyRef'); + allPreserved = false; +} + +console.log(allPreserved ? 'ALL_PRESERVED' : 'SOME_CORRUPTED'); +" 2>&1) + +if echo "$c13_result" | grep -q "ALL_PRESERVED"; then + pass "C13: All non-credential fields preserved correctly" +else + fail "C13: Some non-credential fields were corrupted: ${c13_result}" +fi + +# ══════════════════════════════════════════════════════════════════ +# Phase 6: Shipped Blueprint Digest Check +# ══════════════════════════════════════════════════════════════════ +section "Phase 6: Shipped Blueprint Check" + +# Verify the shipped blueprint.yaml has the known empty digest issue +info "Checking shipped blueprint.yaml digest field..." +BLUEPRINT_FILE="$REPO/nemoclaw-blueprint/blueprint.yaml" +if [ -f "$BLUEPRINT_FILE" ]; then + digest_line=$(grep "^digest:" "$BLUEPRINT_FILE" || true) + if echo "$digest_line" | grep -qE 'digest:\s*""'; then + info "Shipped blueprint has digest: \"\" (empty) — this is the known vulnerability" + info "After PR #156, empty digest will cause a hard verification failure" + pass "Blueprint digest field found and identified" + elif echo "$digest_line" | grep -qE 'digest:\s*$'; then + info "Shipped blueprint has empty digest field" + pass "Blueprint digest field found (empty)" + elif [ -n "$digest_line" ]; then + info "Blueprint digest: $digest_line" + pass "Blueprint has a digest value set" + else + skip "No digest field found in blueprint.yaml" + fi +else + skip "blueprint.yaml not found at $BLUEPRINT_FILE" +fi + +# ══════════════════════════════════════════════════════════════════ +# Summary +# ══════════════════════════════════════════════════════════════════ +echo "" +echo "========================================" +echo " Credential Sanitization Test Results:" +echo " Passed: $PASS" +echo " Failed: $FAIL" +echo " Skipped: $SKIP" +echo " Total: $TOTAL" +echo "========================================" + +if [ "$FAIL" -eq 0 ]; then + printf '\n\033[1;32m Credential sanitization tests PASSED — no credential leaks found.\033[0m\n' + exit 0 +else + printf '\n\033[1;31m %d test(s) failed — CREDENTIAL LEAKS OR BYPASS DETECTED.\033[0m\n' "$FAIL" + exit 1 +fi diff --git a/test/e2e/test-double-onboard.sh b/test/e2e/test-double-onboard.sh index f70d6533e..a9a99e8d7 100755 --- a/test/e2e/test-double-onboard.sh +++ b/test/e2e/test-double-onboard.sh @@ -2,28 +2,25 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -# Double onboard: verify that consecutive `nemoclaw onboard` runs recover -# automatically from stale state (gateway, port forward, registry entries) -# left behind by a previous run. +# Double onboard / lifecycle recovery: +# - prove repeat onboard reuses the healthy shared NemoClaw gateway +# - prove onboarding a second sandbox does not destroy the first sandbox +# - prove stale registry entries are reconciled against live OpenShell state +# - prove gateway rebuilds surface the expected lifecycle guidance # -# Regression test for issues #21, #22, #140, #152, #397. -# -# Key insight: running onboard without NVIDIA_API_KEY in non-interactive -# mode causes process.exit(1) at step 4, but steps 1-3 (preflight, -# gateway, sandbox) complete first — naturally simulating an unclean exit. -# -# Prerequisites: -# - Docker running -# - openshell CLI installed -# - nemoclaw CLI installed -# - NVIDIA_API_KEY must NOT be set -# -# Usage: -# unset NVIDIA_API_KEY -# bash test/e2e/test-double-onboard.sh +# This script intentionally uses a local fake OpenAI-compatible endpoint so it +# matches the current onboarding flow. Older versions of this test relied on a +# missing/invalid NVIDIA_API_KEY causing a late failure after sandbox creation; +# that no longer reflects current non-interactive onboarding behavior. set -uo pipefail +if [ -z "${NEMOCLAW_E2E_NO_TIMEOUT:-}" ]; then + export NEMOCLAW_E2E_NO_TIMEOUT=1 + TIMEOUT_SECONDS="${NEMOCLAW_E2E_TIMEOUT_SECONDS:-900}" + exec timeout -s TERM "$TIMEOUT_SECONDS" "$0" "$@" +fi + PASS=0 FAIL=0 TOTAL=0 @@ -44,22 +41,145 @@ section() { } info() { printf '\033[1;34m [info]\033[0m %s\n' "$1"; } +registry_has() { + local sandbox_name="$1" + [ -f "$REGISTRY" ] && grep -q "$sandbox_name" "$REGISTRY" +} + SANDBOX_A="e2e-double-a" SANDBOX_B="e2e-double-b" REGISTRY="$HOME/.nemoclaw/sandboxes.json" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +FAKE_HOST="127.0.0.1" +FAKE_PORT="${NEMOCLAW_FAKE_PORT:-18080}" +FAKE_BASE_URL="http://${FAKE_HOST}:${FAKE_PORT}/v1" +FAKE_LOG="$(mktemp)" +FAKE_PID="" + +if command -v node >/dev/null 2>&1 && [ -f "$REPO_ROOT/bin/nemoclaw.js" ]; then + NEMOCLAW_CMD=(node "$REPO_ROOT/bin/nemoclaw.js") +else + NEMOCLAW_CMD=(nemoclaw) +fi + +# shellcheck disable=SC2329 +cleanup() { + if [ -n "$FAKE_PID" ] && kill -0 "$FAKE_PID" 2>/dev/null; then + kill "$FAKE_PID" 2>/dev/null || true + wait "$FAKE_PID" 2>/dev/null || true + fi + rm -f "$FAKE_LOG" +} +trap cleanup EXIT + +start_fake_openai() { + python3 - "$FAKE_HOST" "$FAKE_PORT" >"$FAKE_LOG" 2>&1 <<'PY' & +import json +import sys +from http.server import BaseHTTPRequestHandler, HTTPServer + +HOST = sys.argv[1] +PORT = int(sys.argv[2]) + + +class Handler(BaseHTTPRequestHandler): + def _send(self, status, payload): + body = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format, *args): + return + + def do_GET(self): + if self.path in ("/v1/models", "/models"): + self._send(200, {"data": [{"id": "test-model", "object": "model"}]}) + return + self._send(404, {"error": {"message": "not found"}}) + + def do_POST(self): + length = int(self.headers.get("Content-Length", "0")) + if length: + self.rfile.read(length) + if self.path in ("/v1/chat/completions", "/chat/completions"): + self._send( + 200, + { + "id": "chatcmpl-test", + "object": "chat.completion", + "choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}], + }, + ) + return + if self.path in ("/v1/responses", "/responses"): + self._send( + 200, + { + "id": "resp-test", + "object": "response", + "output": [{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "ok"}]}], + }, + ) + return + self._send(404, {"error": {"message": "not found"}}) + + +HTTPServer((HOST, PORT), Handler).serve_forever() +PY + FAKE_PID=$! + + for _ in $(seq 1 20); do + if curl -sf "${FAKE_BASE_URL}/models" >/dev/null 2>&1; then + return 0 + fi + sleep 1 + done + + return 1 +} + +run_onboard() { + local sandbox_name="$1" + local recreate="${2:-0}" + local log_file + log_file="$(mktemp)" + + local -a env_args=( + "COMPATIBLE_API_KEY=dummy" + "NEMOCLAW_NON_INTERACTIVE=1" + "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1" + "NEMOCLAW_PROVIDER=custom" + "NEMOCLAW_ENDPOINT_URL=${FAKE_BASE_URL}" + "NEMOCLAW_MODEL=test-model" + "NEMOCLAW_SANDBOX_NAME=${sandbox_name}" + "NEMOCLAW_POLICY_MODE=skip" + ) + if [ "$recreate" = "1" ]; then + env_args+=("NEMOCLAW_RECREATE_SANDBOX=1") + fi + + env "${env_args[@]}" "${NEMOCLAW_CMD[@]}" onboard --non-interactive >"$log_file" 2>&1 + RUN_ONBOARD_EXIT=$? + RUN_ONBOARD_OUTPUT="$(cat "$log_file")" + rm -f "$log_file" +} + +run_nemoclaw() { + "${NEMOCLAW_CMD[@]}" "$@" +} # ══════════════════════════════════════════════════════════════════ # Phase 0: Pre-cleanup # ══════════════════════════════════════════════════════════════════ section "Phase 0: Pre-cleanup" info "Destroying any leftover test sandboxes/gateway from previous runs..." -# Use nemoclaw destroy (not just openshell sandbox delete) to also clean -# the nemoclaw registry at ~/.nemoclaw/sandboxes.json. Stale registry -# entries from a previous run would cause Phase 2 to exit with -# "Sandbox already exists" before the test even starts. -if command -v nemoclaw >/dev/null 2>&1; then - nemoclaw "$SANDBOX_A" destroy --yes 2>/dev/null || true - nemoclaw "$SANDBOX_B" destroy --yes 2>/dev/null || true +if [ -x "$REPO_ROOT/bin/nemoclaw.js" ] || command -v nemoclaw >/dev/null 2>&1; then + run_nemoclaw "$SANDBOX_A" destroy --yes 2>/dev/null || true + run_nemoclaw "$SANDBOX_B" destroy --yes 2>/dev/null || true fi openshell sandbox delete "$SANDBOX_A" 2>/dev/null || true openshell sandbox delete "$SANDBOX_B" 2>/dev/null || true @@ -68,7 +188,7 @@ openshell gateway destroy -g nemoclaw 2>/dev/null || true pass "Pre-cleanup complete" # ══════════════════════════════════════════════════════════════════ -# Phase 1: Prerequisites +# Phase 1: Prerequisites + fake endpoint # ══════════════════════════════════════════════════════════════════ section "Phase 1: Prerequisites" @@ -86,51 +206,53 @@ else exit 1 fi -if command -v nemoclaw >/dev/null 2>&1; then - pass "nemoclaw CLI installed" +if [ -x "$REPO_ROOT/bin/nemoclaw.js" ] || command -v nemoclaw >/dev/null 2>&1; then + pass "nemoclaw CLI available" else fail "nemoclaw CLI not found — cannot continue" exit 1 fi -if [ -n "${NVIDIA_API_KEY:-}" ]; then - fail "NVIDIA_API_KEY is set — this test requires it UNSET (unset NVIDIA_API_KEY)" +if command -v python3 >/dev/null 2>&1; then + pass "python3 installed" +else + fail "python3 not found — cannot continue" exit 1 +fi + +if start_fake_openai; then + pass "Fake OpenAI-compatible endpoint started at ${FAKE_BASE_URL}" else - pass "NVIDIA_API_KEY is not set (required for controlled step-4 exit)" + fail "Failed to start fake OpenAI-compatible endpoint" + info "Fake server log:" + sed 's/^/ /' "$FAKE_LOG" + exit 1 fi # ══════════════════════════════════════════════════════════════════ -# Phase 2: First onboard (e2e-double-a) — leaves stale state +# Phase 2: First onboard (e2e-double-a) # ══════════════════════════════════════════════════════════════════ section "Phase 2: First onboard ($SANDBOX_A)" -info "Running nemoclaw onboard — expect exit 1 (no API key)..." +info "Running successful non-interactive onboard against local compatible endpoint..." -# Write to temp file to avoid openshell FD inheritance blocking $() -ONBOARD_LOG="$(mktemp)" -NEMOCLAW_NON_INTERACTIVE=1 \ - NEMOCLAW_SANDBOX_NAME="$SANDBOX_A" \ - NEMOCLAW_POLICY_MODE=skip \ - nemoclaw onboard --non-interactive >"$ONBOARD_LOG" 2>&1 -exit1=$? -output1="$(cat "$ONBOARD_LOG")" -rm -f "$ONBOARD_LOG" +run_onboard "$SANDBOX_A" +output1="$RUN_ONBOARD_OUTPUT" +exit1="$RUN_ONBOARD_EXIT" -if [ $exit1 -eq 1 ]; then - pass "First onboard exited 1 (step 4 failed as expected)" +if [ "$exit1" -eq 0 ]; then + pass "First onboard completed successfully" else - fail "First onboard exited $exit1 (expected 1)" + fail "First onboard exited $exit1 (expected 0)" fi if grep -q "Sandbox '${SANDBOX_A}' created" <<<"$output1"; then - pass "Sandbox '$SANDBOX_A' created (step 3 completed)" + pass "Sandbox '$SANDBOX_A' created" else - fail "Sandbox creation not confirmed in output" + fail "Sandbox '$SANDBOX_A' creation not confirmed in output" fi -# Verify stale state was left behind if openshell gateway info -g nemoclaw 2>/dev/null | grep -q "nemoclaw"; then - pass "Gateway is still running (stale state)" + pass "Gateway is running after first onboard" else fail "Gateway is not running after first onboard" fi @@ -141,96 +263,76 @@ else fail "Sandbox '$SANDBOX_A' not found in openshell" fi -if [ -f "$REGISTRY" ] && grep -q "$SANDBOX_A" "$REGISTRY"; then +if registry_has "$SANDBOX_A"; then pass "Registry contains '$SANDBOX_A'" else fail "Registry does not contain '$SANDBOX_A'" fi -info "Stale state confirmed — NOT cleaning up before next onboard" - # ══════════════════════════════════════════════════════════════════ -# Phase 3: Second onboard — SAME name (e2e-double-a) +# Phase 3: Second onboard — SAME name (recreate) # ══════════════════════════════════════════════════════════════════ -section "Phase 3: Second onboard ($SANDBOX_A — same name, stale state)" +section "Phase 3: Second onboard ($SANDBOX_A — same name, recreate)" info "Running nemoclaw onboard with NEMOCLAW_RECREATE_SANDBOX=1..." -ONBOARD_LOG="$(mktemp)" -NEMOCLAW_NON_INTERACTIVE=1 \ - NEMOCLAW_SANDBOX_NAME="$SANDBOX_A" \ - NEMOCLAW_RECREATE_SANDBOX=1 \ - NEMOCLAW_POLICY_MODE=skip \ - nemoclaw onboard --non-interactive >"$ONBOARD_LOG" 2>&1 -exit2=$? -output2="$(cat "$ONBOARD_LOG")" -rm -f "$ONBOARD_LOG" +run_onboard "$SANDBOX_A" "1" +output2="$RUN_ONBOARD_OUTPUT" +exit2="$RUN_ONBOARD_EXIT" -# Step 4 still fails (no API key), but steps 1-3 should succeed -if [ $exit2 -eq 1 ]; then - pass "Second onboard exited 1 (step 4 failed as expected)" +if [ "$exit2" -eq 0 ]; then + pass "Second onboard completed successfully" else - fail "Second onboard exited $exit2 (expected 1)" + fail "Second onboard exited $exit2 (expected 0)" fi -if grep -q "Cleaning up previous NemoClaw session" <<<"$output2"; then - pass "Stale session cleanup fired on second onboard" +if grep -q "Reusing existing NemoClaw gateway" <<<"$output2"; then + pass "Healthy gateway reused on second onboard" else - fail "Stale session cleanup did NOT fire (regression: #397)" + fail "Healthy gateway was not reused on second onboard" fi if grep -q "Port 8080 is not available" <<<"$output2"; then - fail "Port 8080 conflict detected (regression: #21)" + fail "Port 8080 conflict detected (regression)" else - pass "No port 8080 conflict" + pass "No port 8080 conflict on second onboard" fi if grep -q "Port 18789 is not available" <<<"$output2"; then - fail "Port 18789 conflict detected" + fail "Port 18789 conflict detected on second onboard" else - pass "No port 18789 conflict" + pass "No port 18789 conflict on second onboard" fi -if grep -q "Sandbox '${SANDBOX_A}' created" <<<"$output2"; then - pass "Sandbox '$SANDBOX_A' recreated" -else - fail "Sandbox '$SANDBOX_A' was not recreated" -fi - -if openshell gateway info -g nemoclaw 2>/dev/null | grep -q "nemoclaw"; then - pass "Gateway running after second onboard" +if openshell sandbox get "$SANDBOX_A" >/dev/null 2>&1; then + pass "Sandbox '$SANDBOX_A' still exists after recreate" else - fail "Gateway not running after second onboard" + fail "Sandbox '$SANDBOX_A' missing after recreate" fi # ══════════════════════════════════════════════════════════════════ -# Phase 4: Third onboard — DIFFERENT name (e2e-double-b) +# Phase 4: Third onboard — DIFFERENT name # ══════════════════════════════════════════════════════════════════ -section "Phase 4: Third onboard ($SANDBOX_B — different name, stale state)" +section "Phase 4: Third onboard ($SANDBOX_B — different name)" info "Running nemoclaw onboard with new sandbox name..." -ONBOARD_LOG="$(mktemp)" -NEMOCLAW_NON_INTERACTIVE=1 \ - NEMOCLAW_SANDBOX_NAME="$SANDBOX_B" \ - NEMOCLAW_POLICY_MODE=skip \ - nemoclaw onboard --non-interactive >"$ONBOARD_LOG" 2>&1 -exit3=$? -output3="$(cat "$ONBOARD_LOG")" -rm -f "$ONBOARD_LOG" +run_onboard "$SANDBOX_B" +output3="$RUN_ONBOARD_OUTPUT" +exit3="$RUN_ONBOARD_EXIT" -if [ $exit3 -eq 1 ]; then - pass "Third onboard exited 1 (step 4 failed as expected)" +if [ "$exit3" -eq 0 ]; then + pass "Third onboard completed successfully" else - fail "Third onboard exited $exit3 (expected 1)" + fail "Third onboard exited $exit3 (expected 0)" fi -if grep -q "Cleaning up previous NemoClaw session" <<<"$output3"; then - pass "Stale session cleanup fired on third onboard" +if grep -q "Reusing existing NemoClaw gateway" <<<"$output3"; then + pass "Healthy gateway reused on third onboard" else - fail "Stale session cleanup did NOT fire on third onboard" + fail "Healthy gateway was not reused on third onboard" fi if grep -q "Port 8080 is not available" <<<"$output3"; then - fail "Port 8080 conflict on third onboard (regression)" + fail "Port 8080 conflict on third onboard" else pass "No port 8080 conflict on third onboard" fi @@ -241,19 +343,100 @@ else pass "No port 18789 conflict on third onboard" fi -if grep -q "Sandbox '${SANDBOX_B}' created" <<<"$output3"; then +if openshell sandbox get "$SANDBOX_B" >/dev/null 2>&1; then pass "Sandbox '$SANDBOX_B' created" else fail "Sandbox '$SANDBOX_B' was not created" fi +if openshell sandbox get "$SANDBOX_A" >/dev/null 2>&1; then + pass "First sandbox '$SANDBOX_A' still exists after creating '$SANDBOX_B'" +else + fail "First sandbox '$SANDBOX_A' disappeared after creating '$SANDBOX_B' (regression: #849)" +fi + +# ══════════════════════════════════════════════════════════════════ +# Phase 5: Stale registry reconciliation +# ══════════════════════════════════════════════════════════════════ +section "Phase 5: Stale registry reconciliation" +info "Deleting '$SANDBOX_A' directly in OpenShell to leave a stale NemoClaw registry entry..." + +openshell sandbox delete "$SANDBOX_A" 2>/dev/null || true + +if registry_has "$SANDBOX_A"; then + pass "Registry still contains stale '$SANDBOX_A' entry" +else + fail "Registry was unexpectedly cleaned before status reconciliation" +fi + +STATUS_LOG="$(mktemp)" +run_nemoclaw "$SANDBOX_A" status >"$STATUS_LOG" 2>&1 +status_exit=$? +status_output="$(cat "$STATUS_LOG")" +rm -f "$STATUS_LOG" + +if [ "$status_exit" -eq 0 ]; then + pass "Stale sandbox status exited 0" +else + fail "Stale sandbox status exited $status_exit (expected 0)" +fi + +if grep -q "Removed stale local registry entry" <<<"$status_output"; then + pass "Stale registry entry was reconciled during status" +else + fail "Stale registry reconciliation message missing" +fi + +if registry_has "$SANDBOX_A"; then + fail "Registry still contains '$SANDBOX_A' after status reconciliation" +else + pass "Registry entry for '$SANDBOX_A' removed after status reconciliation" +fi + # ══════════════════════════════════════════════════════════════════ -# Phase 5: Final cleanup +# Phase 6: Gateway lifecycle response # ══════════════════════════════════════════════════════════════════ -section "Phase 5: Final cleanup" +section "Phase 6: Gateway lifecycle response" +info "Stopping the NemoClaw gateway runtime to verify current lifecycle behavior..." -nemoclaw "$SANDBOX_A" destroy --yes 2>/dev/null || true -nemoclaw "$SANDBOX_B" destroy --yes 2>/dev/null || true +openshell forward stop 18789 2>/dev/null || true +openshell gateway stop -g nemoclaw 2>/dev/null || true + +GATEWAY_LOG="$(mktemp)" +run_nemoclaw "$SANDBOX_B" status >"$GATEWAY_LOG" 2>&1 +gateway_status_exit=$? +gateway_status_output="$(cat "$GATEWAY_LOG")" +rm -f "$GATEWAY_LOG" + +if [ "$gateway_status_exit" -eq 0 ]; then + pass "Post-stop status exited 0" +else + fail "Post-stop status exited $gateway_status_exit (expected 0)" +fi + +if grep -qE \ + "Recovered NemoClaw gateway runtime|gateway is no longer configured after restart/rebuild|gateway is still refusing connections after restart|gateway trust material rotated after restart" \ + <<<"$gateway_status_output"; then + pass "Gateway lifecycle response was explicit after gateway stop" +else + fail "Gateway lifecycle response was not explicit after gateway stop" + info "Observed status output:" + printf '%s\n' "$gateway_status_output" | sed 's/^/ /' +fi + +if registry_has "$SANDBOX_B"; then + pass "Registry still contains '$SANDBOX_B' after gateway stop" +else + fail "Registry is missing '$SANDBOX_B' after gateway stop" +fi + +# ══════════════════════════════════════════════════════════════════ +# Phase 7: Final cleanup +# ══════════════════════════════════════════════════════════════════ +section "Phase 7: Final cleanup" + +run_nemoclaw "$SANDBOX_A" destroy --yes 2>/dev/null || true +run_nemoclaw "$SANDBOX_B" destroy --yes 2>/dev/null || true openshell sandbox delete "$SANDBOX_A" 2>/dev/null || true openshell sandbox delete "$SANDBOX_B" 2>/dev/null || true openshell forward stop 18789 2>/dev/null || true @@ -279,9 +462,6 @@ fi pass "Final cleanup complete" -# ══════════════════════════════════════════════════════════════════ -# Summary -# ══════════════════════════════════════════════════════════════════ echo "" echo "========================================" echo " Double Onboard E2E Results:" @@ -291,7 +471,7 @@ echo " Total: $TOTAL" echo "========================================" if [ "$FAIL" -eq 0 ]; then - printf '\n\033[1;32m Double onboard PASSED — stale state recovery verified.\033[0m\n' + printf '\n\033[1;32m Double onboard and lifecycle recovery PASSED.\033[0m\n' exit 0 else printf '\n\033[1;31m %d test(s) failed.\033[0m\n' "$FAIL" diff --git a/test/e2e/test-e2e-cloud-experimental.sh b/test/e2e/test-e2e-cloud-experimental.sh index dd5fba943..18b3110a2 100755 --- a/test/e2e/test-e2e-cloud-experimental.sh +++ b/test/e2e/test-e2e-cloud-experimental.sh @@ -8,7 +8,7 @@ # Implemented: Phase 0–1, 3, 5–6. Phase 5 runs checks/*.sh; Phase 5b live chat; Phase 5c skill smoke; Phase 5d skill agent verification; Phase 5f check-docs.sh; # Phase 5e openclaw TUI smoke (expect, non-interactive); Phase 5f check-docs.sh; Phase 6 final cleanup. # Phase 3 default: expect-driven interactive curl|bash (RUN_E2E_CLOUD_EXPERIMENTAL_INTERACTIVE_INSTALL=1). -# Set RUN_E2E_CLOUD_EXPERIMENTAL_INTERACTIVE_INSTALL=0 for non-interactive install (NEMOCLAW_NON_INTERACTIVE=1, no expect). +# Set RUN_E2E_CLOUD_EXPERIMENTAL_INTERACTIVE_INSTALL=0 for non-interactive install (NEMOCLAW_NON_INTERACTIVE=1 and NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1, no expect). # (add checks under e2e-cloud-experimental/checks without editing case loop). VDR3 #12 via env on Phase 3 install. # Phase 2 skipped. Phase 5: checks suite (checks/*.sh only; opt-in scripts live under e2e-cloud-experimental/skip/). # Phase 5b: POST /v1/chat/completions inside sandbox (model = CLOUD_EXPERIMENTAL_MODEL); retries on transient gateway/upstream failures. @@ -17,7 +17,16 @@ # Phase 5e: nemoclaw connect → openclaw tui → send message → repeated Ctrl+C → exit (requires `expect`; skipped if missing or RUN_E2E_CLOUD_EXPERIMENTAL_TUI=0). # Phase 5f: check-docs.sh (Markdown links + nemoclaw --help vs commands.md) before Phase 6; skip with RUN_E2E_CLOUD_EXPERIMENTAL_SKIP_CHECK_DOCS=1. # Inherits CHECK_DOC_LINKS_REMOTE (check-docs.sh defaults to 1 — curl unique http(s) links); set CHECK_DOC_LINKS_REMOTE=0 to skip remote probes only. -# Phase 6: cleanup (skipped when RUN_E2E_CLOUD_EXPERIMENTAL_FROM_PHASE5=1 or RUN_E2E_CLOUD_EXPERIMENTAL_SKIP_FINAL_CLEANUP=1). +# Phase 6: cleanup via e2e-cloud-experimental/cleanup.sh --verify (skipped when RUN_E2E_CLOUD_EXPERIMENTAL_FROM_PHASE5=1 or RUN_E2E_CLOUD_EXPERIMENTAL_SKIP_FINAL_CLEANUP=1). +# Phase 0 pre-cleanup uses the same script without --verify. +# +# Phase tags (optional — slice the suite without editing the script): +# E2E_CLOUD_EXPERIMENTAL_ONLY_TAGS — comma-separated whitelist; unset or "all" = run every phase (still subject to FROM_PHASE5 / SKIP_FINAL_CLEANUP / etc.). +# Example: phase3,phase5b,phase5f +# E2E_CLOUD_EXPERIMENTAL_SKIP_TAGS — comma-separated blacklist applied after ONLY (or alone when ONLY is unset). +# Example: phase0,phase6,phase5e +# Tag names: phase0 phase1 phase2 phase3 phase5 phase5b phase5c phase5d phase5e phase5f phase6 +# Sub-phases (5b–5f) are independent; skipping phase5 does not skip 5b. You must list each phase you want when using ONLY_TAGS. # VDR3 #14 (re-onboard / volume audit) not automated here. # # Optional (not run here): port-8080 onboard conflict — see test/e2e/test-port8080-conflict.sh @@ -27,6 +36,7 @@ # - NVIDIA_API_KEY set (nvapi-...) for Cloud inference segments # - Network to integrate.api.nvidia.com # - NEMOCLAW_NON_INTERACTIVE=1 for automated onboard segments +# - NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 for automated non-interactive install/onboard segments # # Environment (suggested): # Sandbox name is fixed in this script: e2e-cloud-experimental @@ -38,7 +48,7 @@ # NEMOCLAW_POLICY_PRESETS — e.g. npm,pypi (github preset TBD in repo) # RUN_E2E_CLOUD_EXPERIMENTAL_INTERACTIVE=1 — optional: expect-based steps (later phases) # RUN_E2E_CLOUD_EXPERIMENTAL_INTERACTIVE_INSTALL — default 1: Phase 3 uses expect to drive interactive onboard. -# Set to 0 for non-interactive curl|bash (requires NEMOCLAW_NON_INTERACTIVE=1 in host env; no expect on PATH). +# Set to 0 for non-interactive curl|bash (requires NEMOCLAW_NON_INTERACTIVE=1 and NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 in host env; no expect on PATH). # INTERACTIVE_SANDBOX_NAME / INTERACTIVE_RECREATE_ANSWER / INTERACTIVE_INFERENCE_SEND / INTERACTIVE_MODEL_SEND / INTERACTIVE_PRESETS_SEND — see Phase 3 expect branch # DEMO_FAKE_ONLY=1 — expect-only smoke, exit before Phase 0 (offline) # RUN_E2E_CLOUD_EXPERIMENTAL_TUI=0 — skip Phase 5e (openclaw tui expect smoke) @@ -59,10 +69,12 @@ # E2E_CLOUD_EXPERIMENTAL_INSTALL_LOG — Phase 3 install log path (default: /tmp/nemoclaw-e2e-cloud-experimental-install.log) # RUN_E2E_CLOUD_EXPERIMENTAL_SKIP_CHECK_DOCS=1 — skip Phase 5f (check-docs.sh) # CHECK_DOC_LINKS_REMOTE=0 — Phase 5f: skip curling http(s) doc links only (default in check-docs.sh: remote checks on) +# E2E_CLOUD_EXPERIMENTAL_ONLY_TAGS — see header: whitelist phases (e.g. phase5b,phase5c) +# E2E_CLOUD_EXPERIMENTAL_SKIP_TAGS — see header: blacklist phases (e.g. phase6,phase5e) # # Usage (Phases 0–1, 3 + cases + Phase 5b–5f + Phase 6 cleanup; Phase 2 skipped): # NVIDIA_API_KEY=nvapi-... bash test/e2e/test-e2e-cloud-experimental.sh -# Non-interactive install (no expect): RUN_E2E_CLOUD_EXPERIMENTAL_INTERACTIVE_INSTALL=0 NEMOCLAW_NON_INTERACTIVE=1 NVIDIA_API_KEY=nvapi-... bash ... +# Non-interactive install (no expect): RUN_E2E_CLOUD_EXPERIMENTAL_INTERACTIVE_INSTALL=0 NEMOCLAW_NON_INTERACTIVE=1 NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 NVIDIA_API_KEY=nvapi-... bash ... # # Validate only (existing sandbox; no install, no Phase 0/6 teardown): # RUN_E2E_CLOUD_EXPERIMENTAL_FROM_PHASE5=1 NVIDIA_API_KEY=nvapi-... bash test/e2e/test-e2e-cloud-experimental.sh @@ -104,6 +116,36 @@ section() { } info() { printf '\033[1;34m [info]\033[0m %s\n' "$1"; } +# Tag filter: ONLY_TAGS whitelist (unset or "all" = allow all); SKIP_TAGS always removes listed phases. +e2e_cloud_experimental_phase_enabled() { + local phase="$1" + local only="${E2E_CLOUD_EXPERIMENTAL_ONLY_TAGS:-}" + local sk="${E2E_CLOUD_EXPERIMENTAL_SKIP_TAGS:-}" + local tok + local IFS=, + + for tok in $sk; do + tok="${tok// /}" + [ -z "$tok" ] && continue + if [ "$tok" = "$phase" ]; then + return 1 + fi + done + + if [ -z "$only" ] || [ "$only" = "all" ]; then + return 0 + fi + + for tok in $only; do + tok="${tok// /}" + [ -z "$tok" ] && continue + if [ "$tok" = "$phase" ]; then + return 0 + fi + done + return 1 +} + # Parse chat completion JSON — content, reasoning_content, or reasoning (e.g. moonshot/kimi via gateway) parse_chat_content() { python3 -c " @@ -190,20 +232,13 @@ fi # nemoclaw destroy clears ~/.nemoclaw/sandboxes.json; align with test-double-onboard.sh. section "Phase 0: Pre-cleanup" -if [ "${RUN_E2E_CLOUD_EXPERIMENTAL_FROM_PHASE5:-0}" = "1" ]; then +if ! e2e_cloud_experimental_phase_enabled phase0; then + skip "Phase 0: skipped by tag (phase0 — set E2E_CLOUD_EXPERIMENTAL_ONLY_TAGS / SKIP_TAGS)" +elif [ "${RUN_E2E_CLOUD_EXPERIMENTAL_FROM_PHASE5:-0}" = "1" ]; then skip "Phase 0: pre-cleanup skipped (RUN_E2E_CLOUD_EXPERIMENTAL_FROM_PHASE5=1 — preserving sandbox '${SANDBOX_NAME}')" else info "Destroying leftover sandbox, forwards, and gateway for '${SANDBOX_NAME}'..." - - if command -v nemoclaw >/dev/null 2>&1; then - nemoclaw "$SANDBOX_NAME" destroy --yes 2>/dev/null || true - fi - if command -v openshell >/dev/null 2>&1; then - openshell sandbox delete "$SANDBOX_NAME" 2>/dev/null || true - openshell forward stop 18789 2>/dev/null || true - openshell gateway destroy -g nemoclaw 2>/dev/null || true - fi - + SANDBOX_NAME="$SANDBOX_NAME" bash "${E2E_DIR}/e2e-cloud-experimental/cleanup.sh" pass "Pre-cleanup complete" fi @@ -211,52 +246,58 @@ fi # Phase 1: Prerequisites # ══════════════════════════════════════════════════════════════════════ # Docker running; NVIDIA_API_KEY format; reach integrate.api.nvidia.com; -# NEMOCLAW_NON_INTERACTIVE=1 for automated path; optional: assert Linux + Docker CE. +# NEMOCLAW_NON_INTERACTIVE=1 and NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 for automated path; optional: assert Linux + Docker CE. section "Phase 1: Prerequisites" -if docker info >/dev/null 2>&1; then +if ! e2e_cloud_experimental_phase_enabled phase1; then + skip "Phase 1: skipped by tag (phase1 — set E2E_CLOUD_EXPERIMENTAL_ONLY_TAGS / SKIP_TAGS)" +elif docker info >/dev/null 2>&1; then pass "Docker is running" -else - fail "Docker is not running — cannot continue" - exit 1 -fi -if [ -n "${NVIDIA_API_KEY:-}" ] && [[ "${NVIDIA_API_KEY}" == nvapi-* ]]; then - pass "NVIDIA_API_KEY is set (starts with nvapi-)" -else - fail "NVIDIA_API_KEY not set or invalid — required for e2e-cloud-experimental (Cloud API)" - exit 1 -fi + if [ -n "${NVIDIA_API_KEY:-}" ] && [[ "${NVIDIA_API_KEY}" == nvapi-* ]]; then + pass "NVIDIA_API_KEY is set (starts with nvapi-)" + else + fail "NVIDIA_API_KEY not set or invalid — required for e2e-cloud-experimental (Cloud API)" + exit 1 + fi -if curl -sf --max-time 10 https://integrate.api.nvidia.com/v1/models >/dev/null 2>&1; then - pass "Network access to integrate.api.nvidia.com" -else - fail "Cannot reach integrate.api.nvidia.com" - exit 1 -fi + if curl -sf --max-time 10 https://integrate.api.nvidia.com/v1/models >/dev/null 2>&1; then + pass "Network access to integrate.api.nvidia.com" + else + fail "Cannot reach integrate.api.nvidia.com" + exit 1 + fi -if [ "${RUN_E2E_CLOUD_EXPERIMENTAL_FROM_PHASE5:-0}" = "1" ]; then - pass "Phase 1: FROM_PHASE5 mode (NEMOCLAW_NON_INTERACTIVE not required)" -elif [ "${RUN_E2E_CLOUD_EXPERIMENTAL_INTERACTIVE_INSTALL:-1}" = "1" ]; then - pass "Phase 1: interactive install mode (NEMOCLAW_NON_INTERACTIVE not required on host)" -elif [ "${NEMOCLAW_NON_INTERACTIVE:-}" != "1" ]; then - fail "NEMOCLAW_NON_INTERACTIVE=1 is required when RUN_E2E_CLOUD_EXPERIMENTAL_INTERACTIVE_INSTALL=0 (or use default interactive install, or RUN_E2E_CLOUD_EXPERIMENTAL_FROM_PHASE5=1)" - exit 1 -else - pass "NEMOCLAW_NON_INTERACTIVE=1" -fi + if [ "${RUN_E2E_CLOUD_EXPERIMENTAL_FROM_PHASE5:-0}" = "1" ]; then + pass "Phase 1: FROM_PHASE5 mode (NEMOCLAW_NON_INTERACTIVE not required)" + elif [ "${RUN_E2E_CLOUD_EXPERIMENTAL_INTERACTIVE_INSTALL:-1}" = "1" ]; then + pass "Phase 1: interactive install mode (NEMOCLAW_NON_INTERACTIVE not required on host)" + elif [ "${NEMOCLAW_NON_INTERACTIVE:-}" != "1" ]; then + fail "NEMOCLAW_NON_INTERACTIVE=1 is required when RUN_E2E_CLOUD_EXPERIMENTAL_INTERACTIVE_INSTALL=0 (or use default interactive install, or RUN_E2E_CLOUD_EXPERIMENTAL_FROM_PHASE5=1)" + exit 1 + elif [ "${NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE:-}" != "1" ]; then + fail "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 is required when RUN_E2E_CLOUD_EXPERIMENTAL_INTERACTIVE_INSTALL=0" + exit 1 + else + pass "NEMOCLAW_NON_INTERACTIVE=1" + pass "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1" + fi -# Nominal scenario: Ubuntu + Docker (Linux + Docker in README). Others may still run; do not hard-fail on macOS. -if [[ "$(uname -s)" == "Linux" ]]; then - pass "Host OS is Linux (nominal for e2e-cloud-experimental / README)" -else - skip "Host is not Linux — e2e-cloud-experimental nominally targets Ubuntu (continuing)" -fi + # Nominal scenario: Ubuntu + Docker (Linux + Docker in README). Others may still run; do not hard-fail on macOS. + if [[ "$(uname -s)" == "Linux" ]]; then + pass "Host OS is Linux (nominal for e2e-cloud-experimental / README)" + else + skip "Host is not Linux — e2e-cloud-experimental nominally targets Ubuntu (continuing)" + fi -if srv_ver=$(docker version -f '{{.Server.Version}}' 2>/dev/null) && [ -n "$srv_ver" ]; then - pass "Docker server version reported (${srv_ver})" + if srv_ver=$(docker version -f '{{.Server.Version}}' 2>/dev/null) && [ -n "$srv_ver" ]; then + pass "Docker server version reported (${srv_ver})" + else + skip "Could not read docker server version from docker version" + fi else - skip "Could not read docker server version from docker version" + fail "Docker is not running — cannot continue" + exit 1 fi # ══════════════════════════════════════════════════════════════════════ @@ -264,7 +305,12 @@ fi # ══════════════════════════════════════════════════════════════════════ # Deferred by request — not part of e2e-cloud-experimental for now. section "Phase 2: Doc review (README prerequisites) — skipped" -skip "Phase 2: doc review (VDR3 #11) — not required for now" + +if ! e2e_cloud_experimental_phase_enabled phase2; then + skip "Phase 2: skipped by tag (phase2 — set E2E_CLOUD_EXPERIMENTAL_ONLY_TAGS / SKIP_TAGS)" +else + skip "Phase 2: doc review (VDR3 #11) — not required for now" +fi # ══════════════════════════════════════════════════════════════════════ # Phase 3: Install + PATH (VDR3 #7, #10) @@ -274,44 +320,45 @@ skip "Phase 2: doc review (VDR3 #11) — not required for now" # nemoclaw onboard — no second onboard pass needed. section "Phase 3: Install and PATH" -cd "$REPO" || { +if ! e2e_cloud_experimental_phase_enabled phase3; then + skip "Phase 3: skipped by tag (phase3 — set E2E_CLOUD_EXPERIMENTAL_ONLY_TAGS / SKIP_TAGS)" +elif ! cd "$REPO"; then fail "Could not cd to repo root: $REPO" exit 1 -} - -export NEMOCLAW_SANDBOX_NAME="$SANDBOX_NAME" -export NEMOCLAW_EXPERIMENTAL=1 -export NEMOCLAW_PROVIDER=cloud -export NEMOCLAW_MODEL="$CLOUD_EXPERIMENTAL_MODEL" -export NEMOCLAW_POLICY_MODE="${NEMOCLAW_POLICY_MODE:-custom}" -export NEMOCLAW_POLICY_PRESETS="${NEMOCLAW_POLICY_PRESETS:-npm,pypi}" +else + export NEMOCLAW_SANDBOX_NAME="$SANDBOX_NAME" + export NEMOCLAW_EXPERIMENTAL=1 + export NEMOCLAW_PROVIDER=cloud + export NEMOCLAW_MODEL="$CLOUD_EXPERIMENTAL_MODEL" + export NEMOCLAW_POLICY_MODE="${NEMOCLAW_POLICY_MODE:-custom}" + export NEMOCLAW_POLICY_PRESETS="${NEMOCLAW_POLICY_PRESETS:-npm,pypi}" -NEMOCLAW_INSTALL_SCRIPT_URL="${NEMOCLAW_INSTALL_SCRIPT_URL:-https://www.nvidia.com/nemoclaw.sh}" -export NEMOCLAW_INSTALL_SCRIPT_URL + NEMOCLAW_INSTALL_SCRIPT_URL="${NEMOCLAW_INSTALL_SCRIPT_URL:-https://www.nvidia.com/nemoclaw.sh}" + export NEMOCLAW_INSTALL_SCRIPT_URL -# Override when running in Docker CI with a host-mounted log dir (see test/e2e/Dockerfile.cloud-experimental). -INSTALL_LOG="${E2E_CLOUD_EXPERIMENTAL_INSTALL_LOG:-/tmp/nemoclaw-e2e-cloud-experimental-install.log}" + # Override when running in Docker CI with a host-mounted log dir (see test/e2e/Dockerfile.cloud-experimental). + INSTALL_LOG="${E2E_CLOUD_EXPERIMENTAL_INSTALL_LOG:-/tmp/nemoclaw-e2e-cloud-experimental-install.log}" -if [ "${RUN_E2E_CLOUD_EXPERIMENTAL_FROM_PHASE5:-0}" = "1" ]; then - info "Phase 3: skipping curl|bash install (RUN_E2E_CLOUD_EXPERIMENTAL_FROM_PHASE5=1)" - install_exit=0 -elif [ "${RUN_E2E_CLOUD_EXPERIMENTAL_INTERACTIVE_INSTALL:-1}" = "1" ]; then - if ! command -v expect >/dev/null 2>&1; then - fail "Phase 3: expect not on PATH (install expect, or set RUN_E2E_CLOUD_EXPERIMENTAL_INTERACTIVE_INSTALL=0 for non-interactive install)" - exit 1 - fi - export INTERACTIVE_SANDBOX_NAME="${INTERACTIVE_SANDBOX_NAME:-$SANDBOX_NAME}" - export INTERACTIVE_RECREATE_ANSWER="${INTERACTIVE_RECREATE_ANSWER:-n}" - export INTERACTIVE_INFERENCE_SEND="${INTERACTIVE_INFERENCE_SEND:-}" - export INTERACTIVE_MODEL_SEND="${INTERACTIVE_MODEL_SEND:-}" - export INTERACTIVE_PRESETS_SEND="${INTERACTIVE_PRESETS_SEND:-y}" - info "Phase 3: expect-driven interactive curl|bash (URL=${NEMOCLAW_INSTALL_SCRIPT_URL}, sandbox=${INTERACTIVE_SANDBOX_NAME})" - info "Output streams to this terminal AND ${INSTALL_LOG} (via tee) — first prompts may take several minutes after curl/Node install." - if [[ -z "${NVIDIA_API_KEY:-}" ]]; then - info "WARN: NVIDIA_API_KEY unset; expect will fail at API key prompt unless credentials exist on disk." - fi - set +e - expect <<'EXPECT' 2>&1 | tee "$INSTALL_LOG" + if [ "${RUN_E2E_CLOUD_EXPERIMENTAL_FROM_PHASE5:-0}" = "1" ]; then + info "Phase 3: skipping curl|bash install (RUN_E2E_CLOUD_EXPERIMENTAL_FROM_PHASE5=1)" + install_exit=0 + elif [ "${RUN_E2E_CLOUD_EXPERIMENTAL_INTERACTIVE_INSTALL:-1}" = "1" ]; then + if ! command -v expect >/dev/null 2>&1; then + fail "Phase 3: expect not on PATH (install expect, or set RUN_E2E_CLOUD_EXPERIMENTAL_INTERACTIVE_INSTALL=0 for non-interactive install)" + exit 1 + fi + export INTERACTIVE_SANDBOX_NAME="${INTERACTIVE_SANDBOX_NAME:-$SANDBOX_NAME}" + export INTERACTIVE_RECREATE_ANSWER="${INTERACTIVE_RECREATE_ANSWER:-n}" + export INTERACTIVE_INFERENCE_SEND="${INTERACTIVE_INFERENCE_SEND:-}" + export INTERACTIVE_MODEL_SEND="${INTERACTIVE_MODEL_SEND:-}" + export INTERACTIVE_PRESETS_SEND="${INTERACTIVE_PRESETS_SEND:-y}" + info "Phase 3: expect-driven interactive curl|bash (URL=${NEMOCLAW_INSTALL_SCRIPT_URL}, sandbox=${INTERACTIVE_SANDBOX_NAME})" + info "Output streams to this terminal AND ${INSTALL_LOG} (via tee) — first prompts may take several minutes after curl/Node install." + if [[ -z "${NVIDIA_API_KEY:-}" ]]; then + info "WARN: NVIDIA_API_KEY unset; expect will fail at API key prompt unless credentials exist on disk." + fi + set +e + expect <<'EXPECT' 2>&1 | tee "$INSTALL_LOG" set timeout -1 if {![info exists env(NEMOCLAW_INSTALL_SCRIPT_URL)]} { @@ -384,75 +431,77 @@ expect { } } EXPECT - install_exit=${PIPESTATUS[0]} - set -uo pipefail -else - info "Running: curl -fsSL ${NEMOCLAW_INSTALL_SCRIPT_URL} | bash" - info "Onboard uses EXPERIMENTAL=1, PROVIDER=cloud, MODEL=${CLOUD_EXPERIMENTAL_MODEL} (override: NEMOCLAW_CLOUD_EXPERIMENTAL_MODEL or legacy NEMOCLAW_SCENARIO_A_MODEL)." - info "Policy: NEMOCLAW_POLICY_MODE=${NEMOCLAW_POLICY_MODE} NEMOCLAW_POLICY_PRESETS=${NEMOCLAW_POLICY_PRESETS} (override env to change)." - info "Installs Node.js, openshell, NemoClaw, and runs onboard — may take several minutes." - - curl -fsSL "$NEMOCLAW_INSTALL_SCRIPT_URL" | bash >"$INSTALL_LOG" 2>&1 & - install_pid=$! - tail -f "$INSTALL_LOG" --pid=$install_pid 2>/dev/null & - tail_pid=$! - wait "$install_pid" - install_exit=$? - kill "$tail_pid" 2>/dev/null || true - wait "$tail_pid" 2>/dev/null || true -fi + install_exit=${PIPESTATUS[0]} + set -uo pipefail + else + info "Running: curl -fsSL ${NEMOCLAW_INSTALL_SCRIPT_URL} | bash" + info "Onboard uses EXPERIMENTAL=1, PROVIDER=cloud, MODEL=${CLOUD_EXPERIMENTAL_MODEL} (override: NEMOCLAW_CLOUD_EXPERIMENTAL_MODEL or legacy NEMOCLAW_SCENARIO_A_MODEL)." + info "Policy: NEMOCLAW_POLICY_MODE=${NEMOCLAW_POLICY_MODE} NEMOCLAW_POLICY_PRESETS=${NEMOCLAW_POLICY_PRESETS} (override env to change)." + info "Installs Node.js, openshell, NemoClaw, and runs onboard — may take several minutes." + + curl -fsSL "$NEMOCLAW_INSTALL_SCRIPT_URL" | bash >"$INSTALL_LOG" 2>&1 & + install_pid=$! + tail -f "$INSTALL_LOG" --pid=$install_pid 2>/dev/null & + tail_pid=$! + wait "$install_pid" + install_exit=$? + kill "$tail_pid" 2>/dev/null || true + wait "$tail_pid" 2>/dev/null || true + fi -if [ -f "$HOME/.bashrc" ]; then + if [ -f "$HOME/.bashrc" ]; then + # shellcheck source=/dev/null + source "$HOME/.bashrc" 2>/dev/null || true + fi + export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" # shellcheck source=/dev/null - source "$HOME/.bashrc" 2>/dev/null || true -fi -export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" -# shellcheck source=/dev/null -[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" -if [ -d "$HOME/.local/bin" ] && [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then - export PATH="$HOME/.local/bin:$PATH" -fi + [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" + if [ -d "$HOME/.local/bin" ] && [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then + export PATH="$HOME/.local/bin:$PATH" + fi -if [ "$install_exit" -eq 0 ]; then - if [ "${RUN_E2E_CLOUD_EXPERIMENTAL_FROM_PHASE5:-0}" = "1" ]; then - pass "Phase 3: install skipped (FROM_PHASE5); using existing sandbox '${SANDBOX_NAME}'" - elif [ "${RUN_E2E_CLOUD_EXPERIMENTAL_INTERACTIVE_INSTALL:-1}" = "1" ]; then - pass "public install (expect interactive curl|bash) completed (exit 0)" + if [ "$install_exit" -eq 0 ]; then + if [ "${RUN_E2E_CLOUD_EXPERIMENTAL_FROM_PHASE5:-0}" = "1" ]; then + pass "Phase 3: install skipped (FROM_PHASE5); using existing sandbox '${SANDBOX_NAME}'" + elif [ "${RUN_E2E_CLOUD_EXPERIMENTAL_INTERACTIVE_INSTALL:-1}" = "1" ]; then + pass "public install (expect interactive curl|bash) completed (exit 0)" + else + pass "public install (curl nemoclaw.sh | bash) completed (exit 0)" + fi else - pass "public install (curl nemoclaw.sh | bash) completed (exit 0)" + if [ "${RUN_E2E_CLOUD_EXPERIMENTAL_INTERACTIVE_INSTALL:-1}" = "1" ]; then + fail "public install (expect interactive curl|bash) failed (exit $install_exit)" + else + fail "public install (curl nemoclaw.sh | bash) failed (exit $install_exit)" + fi + exit 1 fi -else - if [ "${RUN_E2E_CLOUD_EXPERIMENTAL_INTERACTIVE_INSTALL:-1}" = "1" ]; then - fail "public install (expect interactive curl|bash) failed (exit $install_exit)" + + if command -v nemoclaw >/dev/null 2>&1; then + pass "nemoclaw on PATH ($(command -v nemoclaw))" else - fail "public install (curl nemoclaw.sh | bash) failed (exit $install_exit)" + _e2e_path_ctx="after install" + [ "${RUN_E2E_CLOUD_EXPERIMENTAL_FROM_PHASE5:-0}" = "1" ] && _e2e_path_ctx="required for Phase 5" + fail "nemoclaw not found on PATH (${_e2e_path_ctx})" + exit 1 fi - exit 1 -fi -if command -v nemoclaw >/dev/null 2>&1; then - pass "nemoclaw on PATH ($(command -v nemoclaw))" -else - _e2e_path_ctx="after install" - [ "${RUN_E2E_CLOUD_EXPERIMENTAL_FROM_PHASE5:-0}" = "1" ] && _e2e_path_ctx="required for Phase 5" - fail "nemoclaw not found on PATH (${_e2e_path_ctx})" - exit 1 -fi + if command -v openshell >/dev/null 2>&1; then + pass "openshell on PATH ($(openshell --version 2>&1 || echo unknown))" + else + _e2e_path_ctx="after install" + [ "${RUN_E2E_CLOUD_EXPERIMENTAL_FROM_PHASE5:-0}" = "1" ] && _e2e_path_ctx="required for Phase 5" + fail "openshell not found on PATH (${_e2e_path_ctx})" + exit 1 + fi -if command -v openshell >/dev/null 2>&1; then - pass "openshell on PATH ($(openshell --version 2>&1 || echo unknown))" -else - _e2e_path_ctx="after install" - [ "${RUN_E2E_CLOUD_EXPERIMENTAL_FROM_PHASE5:-0}" = "1" ] && _e2e_path_ctx="required for Phase 5" - fail "openshell not found on PATH (${_e2e_path_ctx})" - exit 1 -fi + if nemoclaw --help >/dev/null 2>&1; then + pass "nemoclaw --help exits 0" + else + fail "nemoclaw --help failed" + exit 1 + fi -if nemoclaw --help >/dev/null 2>&1; then - pass "nemoclaw --help exits 0" -else - fail "nemoclaw --help failed" - exit 1 fi # ══════════════════════════════════════════════════════════════════════ @@ -461,29 +510,33 @@ fi # Ready scripts are sorted by filename; each must exit 0 on success. See e2e-cloud-experimental/README.md. section "Phase 5: Sandbox checks suite (then Phase 5b chat + Phase 5c skill smoke in this script)" -export SANDBOX_NAME CLOUD_EXPERIMENTAL_MODEL REPO NVIDIA_API_KEY +if ! e2e_cloud_experimental_phase_enabled phase5; then + skip "Phase 5: skipped by tag (phase5 — checks/*.sh only; 5b–5f use their own tags)" +else + export SANDBOX_NAME CLOUD_EXPERIMENTAL_MODEL REPO NVIDIA_API_KEY -shopt -s nullglob -case_scripts=("$E2E_CLOUD_EXPERIMENTAL_READY_DIR"/*.sh) -shopt -u nullglob + shopt -s nullglob + case_scripts=("$E2E_CLOUD_EXPERIMENTAL_READY_DIR"/*.sh) + shopt -u nullglob -if [ "${#case_scripts[@]}" -eq 0 ]; then - skip "No checks scripts in ${E2E_CLOUD_EXPERIMENTAL_READY_DIR} (add checks/*.sh)" -else - info "Checks directory: ${E2E_CLOUD_EXPERIMENTAL_READY_DIR} (${#case_scripts[@]} script(s))" - for case_script in "${case_scripts[@]}"; do - info "Running $(basename "$case_script")..." - set +e - bash "$case_script" - c_rc=$? - set -uo pipefail - if [ "$c_rc" -eq 0 ]; then - pass "case $(basename "$case_script" .sh)" - else - fail "case $(basename "$case_script" .sh) exited ${c_rc}" - exit 1 - fi - done + if [ "${#case_scripts[@]}" -eq 0 ]; then + skip "No checks scripts in ${E2E_CLOUD_EXPERIMENTAL_READY_DIR} (add checks/*.sh)" + else + info "Checks directory: ${E2E_CLOUD_EXPERIMENTAL_READY_DIR} (${#case_scripts[@]} script(s))" + for case_script in "${case_scripts[@]}"; do + info "Running $(basename "$case_script")..." + set +e + bash "$case_script" + c_rc=$? + set -uo pipefail + if [ "$c_rc" -eq 0 ]; then + pass "case $(basename "$case_script" .sh)" + else + fail "case $(basename "$case_script" .sh) exited ${c_rc}" + exit 1 + fi + done + fi fi # ══════════════════════════════════════════════════════════════════════ @@ -492,12 +545,15 @@ fi # Same path as test-full-e2e.sh 4b: sandbox → gateway → cloud; model from CLOUD_EXPERIMENTAL_MODEL. section "Phase 5b: Live chat (inference.local /v1/chat/completions)" -if ! command -v python3 >/dev/null 2>&1; then - fail "Phase 5b: python3 not on PATH (needed to parse chat response)" - exit 1 -fi +if ! e2e_cloud_experimental_phase_enabled phase5b; then + skip "Phase 5b: skipped by tag (phase5b — set E2E_CLOUD_EXPERIMENTAL_ONLY_TAGS / SKIP_TAGS)" +else + if ! command -v python3 >/dev/null 2>&1; then + fail "Phase 5b: python3 not on PATH (needed to parse chat response)" + exit 1 + fi -payload=$(CLOUD_EXPERIMENTAL_MODEL="$CLOUD_EXPERIMENTAL_MODEL" python3 -c " + payload=$(CLOUD_EXPERIMENTAL_MODEL="$CLOUD_EXPERIMENTAL_MODEL" python3 -c " import json, os print(json.dumps({ 'model': os.environ['CLOUD_EXPERIMENTAL_MODEL'], @@ -505,75 +561,77 @@ print(json.dumps({ 'max_tokens': 100, })) ") || { - fail "Phase 5b: could not build chat JSON payload" - exit 1 -} + fail "Phase 5b: could not build chat JSON payload" + exit 1 + } -PHASE_5B_MAX="${E2E_PHASE_5B_MAX_ATTEMPTS:-3}" -PHASE_5B_SLEEP="${E2E_PHASE_5B_RETRY_SLEEP_SEC:-5}" -# Clamp to at least 1 attempt -if ! [[ "$PHASE_5B_MAX" =~ ^[1-9][0-9]*$ ]]; then - PHASE_5B_MAX=3 -fi -info "POST chat completion inside sandbox (model ${CLOUD_EXPERIMENTAL_MODEL}, up to ${PHASE_5B_MAX} attempt(s), ${PHASE_5B_SLEEP}s between retries)..." + PHASE_5B_MAX="${E2E_PHASE_5B_MAX_ATTEMPTS:-3}" + PHASE_5B_SLEEP="${E2E_PHASE_5B_RETRY_SLEEP_SEC:-5}" + # Clamp to at least 1 attempt + if ! [[ "$PHASE_5B_MAX" =~ ^[1-9][0-9]*$ ]]; then + PHASE_5B_MAX=3 + fi + info "POST chat completion inside sandbox (model ${CLOUD_EXPERIMENTAL_MODEL}, up to ${PHASE_5B_MAX} attempt(s), ${PHASE_5B_SLEEP}s between retries)..." -CHAT_TIMEOUT_CMD="" -command -v timeout >/dev/null 2>&1 && CHAT_TIMEOUT_CMD="timeout 120" -command -v gtimeout >/dev/null 2>&1 && CHAT_TIMEOUT_CMD="gtimeout 120" + CHAT_TIMEOUT_CMD="" + command -v timeout >/dev/null 2>&1 && CHAT_TIMEOUT_CMD="timeout 120" + command -v gtimeout >/dev/null 2>&1 && CHAT_TIMEOUT_CMD="gtimeout 120" -ssh_config_chat="$(mktemp)" -if ! openshell sandbox ssh-config "$SANDBOX_NAME" >"$ssh_config_chat" 2>/dev/null; then - rm -f "$ssh_config_chat" - fail "Phase 5b: openshell sandbox ssh-config failed for '${SANDBOX_NAME}'" - exit 1 -fi + ssh_config_chat="$(mktemp)" + if ! openshell sandbox ssh-config "$SANDBOX_NAME" >"$ssh_config_chat" 2>/dev/null; then + rm -f "$ssh_config_chat" + fail "Phase 5b: openshell sandbox ssh-config failed for '${SANDBOX_NAME}'" + exit 1 + fi -phase_5b_attempt=1 -phase_5b_ok=0 -phase_5b_last_fail="" -while [ "$phase_5b_attempt" -le "$PHASE_5B_MAX" ]; do - set +e - sandbox_chat_out=$( - $CHAT_TIMEOUT_CMD ssh -F "$ssh_config_chat" \ - -o StrictHostKeyChecking=no \ - -o UserKnownHostsFile=/dev/null \ - -o ConnectTimeout=10 \ - -o LogLevel=ERROR \ - "openshell-${SANDBOX_NAME}" \ - "curl -sS --max-time 90 https://inference.local/v1/chat/completions -H 'Content-Type: application/json' -d $(printf '%q' "$payload")" \ - 2>&1 - ) - chat_ssh_rc=$? - set -uo pipefail + phase_5b_attempt=1 + phase_5b_ok=0 + phase_5b_last_fail="" + while [ "$phase_5b_attempt" -le "$PHASE_5B_MAX" ]; do + set +e + sandbox_chat_out=$( + $CHAT_TIMEOUT_CMD ssh -F "$ssh_config_chat" \ + -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null \ + -o ConnectTimeout=10 \ + -o LogLevel=ERROR \ + "openshell-${SANDBOX_NAME}" \ + "curl -sS --max-time 90 https://inference.local/v1/chat/completions -H 'Content-Type: application/json' -d $(printf '%q' "$payload")" \ + 2>&1 + ) + chat_ssh_rc=$? + set -uo pipefail - if [ "$chat_ssh_rc" -ne 0 ]; then - phase_5b_last_fail="Phase 5b: ssh/curl failed (exit ${chat_ssh_rc}): ${sandbox_chat_out:0:400}" - elif [ -z "$sandbox_chat_out" ]; then - phase_5b_last_fail="Phase 5b: empty response from inference.local chat completions" - else - chat_text=$(printf '%s' "$sandbox_chat_out" | parse_chat_content 2>/dev/null) || chat_text="" - if echo "$chat_text" | grep -qi "PONG"; then - pass "Phase 5b: chat completion returned PONG (model ${CLOUD_EXPERIMENTAL_MODEL}, attempt ${phase_5b_attempt}/${PHASE_5B_MAX})" - phase_5b_ok=1 + if [ "$chat_ssh_rc" -ne 0 ]; then + phase_5b_last_fail="Phase 5b: ssh/curl failed (exit ${chat_ssh_rc}): ${sandbox_chat_out:0:400}" + elif [ -z "$sandbox_chat_out" ]; then + phase_5b_last_fail="Phase 5b: empty response from inference.local chat completions" + else + chat_text=$(printf '%s' "$sandbox_chat_out" | parse_chat_content 2>/dev/null) || chat_text="" + if echo "$chat_text" | grep -qi "PONG"; then + pass "Phase 5b: chat completion returned PONG (model ${CLOUD_EXPERIMENTAL_MODEL}, attempt ${phase_5b_attempt}/${PHASE_5B_MAX})" + phase_5b_ok=1 + break + fi + phase_5b_last_fail="Phase 5b: expected PONG in assistant text, got: ${chat_text:0:300} (raw: ${sandbox_chat_out:0:400})" + fi + + if [ "$phase_5b_attempt" -ge "$PHASE_5B_MAX" ]; then break fi - phase_5b_last_fail="Phase 5b: expected PONG in assistant text, got: ${chat_text:0:300} (raw: ${sandbox_chat_out:0:400})" - fi + info "Phase 5b: attempt ${phase_5b_attempt}/${PHASE_5B_MAX} failed — ${phase_5b_last_fail#Phase 5b: }" + info "Phase 5b: sleeping ${PHASE_5B_SLEEP}s before retry..." + sleep "$PHASE_5B_SLEEP" + phase_5b_attempt=$((phase_5b_attempt + 1)) + done - if [ "$phase_5b_attempt" -ge "$PHASE_5B_MAX" ]; then - break - fi - info "Phase 5b: attempt ${phase_5b_attempt}/${PHASE_5B_MAX} failed — ${phase_5b_last_fail#Phase 5b: }" - info "Phase 5b: sleeping ${PHASE_5B_SLEEP}s before retry..." - sleep "$PHASE_5B_SLEEP" - phase_5b_attempt=$((phase_5b_attempt + 1)) -done + rm -f "$ssh_config_chat" -rm -f "$ssh_config_chat" + if [ "$phase_5b_ok" -ne 1 ]; then + fail "$phase_5b_last_fail" + exit 1 + fi -if [ "$phase_5b_ok" -ne 1 ]; then - fail "$phase_5b_last_fail" - exit 1 fi # ══════════════════════════════════════════════════════════════════════ @@ -584,32 +642,37 @@ fi # skills subdir is optional (migration); absent → honest SKIP (not PASS). section "Phase 5c: Skill smoke (repo + sandbox OpenClaw)" -info "Validating repo .agents/skills (SKILL.md frontmatter + body)..." -if ! bash "$E2E_DIR/e2e-cloud-experimental/features/skill/lib/validate_repo_skills.sh" --repo "$REPO"; then - fail "Phase 5c: repo skill validation failed" - exit 1 -fi -pass "Phase 5c: repo agent skills (SKILL.md) valid" +if ! e2e_cloud_experimental_phase_enabled phase5c; then + skip "Phase 5c: skipped by tag (phase5c — set E2E_CLOUD_EXPERIMENTAL_ONLY_TAGS / SKIP_TAGS)" +else + info "Validating repo .agents/skills (SKILL.md frontmatter + body)..." + if ! bash "$E2E_DIR/e2e-cloud-experimental/features/skill/lib/validate_repo_skills.sh" --repo "$REPO"; then + fail "Phase 5c: repo skill validation failed" + exit 1 + fi + pass "Phase 5c: repo agent skills (SKILL.md) valid" -info "Checking /sandbox/.openclaw inside sandbox..." -set +e -sb_out=$(SANDBOX_NAME="$SANDBOX_NAME" bash "$E2E_DIR/e2e-cloud-experimental/features/skill/lib/validate_sandbox_openclaw_skills.sh" 2>/dev/null) -sb_rc=$? -set -uo pipefail + info "Checking /sandbox/.openclaw inside sandbox..." + set +e + sb_out=$(SANDBOX_NAME="$SANDBOX_NAME" bash "$E2E_DIR/e2e-cloud-experimental/features/skill/lib/validate_sandbox_openclaw_skills.sh" 2>/dev/null) + sb_rc=$? + set -uo pipefail -if [ "$sb_rc" -ne 0 ]; then - fail "Phase 5c: sandbox OpenClaw layout check failed (exit ${sb_rc}): ${sb_out:0:240}" - exit 1 -fi -pass "Phase 5c: sandbox /sandbox/.openclaw + openclaw.json OK" + if [ "$sb_rc" -ne 0 ]; then + fail "Phase 5c: sandbox OpenClaw layout check failed (exit ${sb_rc}): ${sb_out:0:240}" + exit 1 + fi + pass "Phase 5c: sandbox /sandbox/.openclaw + openclaw.json OK" + + if echo "$sb_out" | grep -q "SKILLS_SUBDIR=present"; then + pass "Phase 5c: sandbox /sandbox/.openclaw/skills present" + elif echo "$sb_out" | grep -q "SKILLS_SUBDIR=absent"; then + skip "Phase 5c: /sandbox/.openclaw/skills absent (host migration snapshot had no skills dir)" + else + fail "Phase 5c: unexpected sandbox check output: ${sb_out:0:240}" + exit 1 + fi -if echo "$sb_out" | grep -q "SKILLS_SUBDIR=present"; then - pass "Phase 5c: sandbox /sandbox/.openclaw/skills present" -elif echo "$sb_out" | grep -q "SKILLS_SUBDIR=absent"; then - skip "Phase 5c: /sandbox/.openclaw/skills absent (host migration snapshot had no skills dir)" -else - fail "Phase 5c: unexpected sandbox check output: ${sb_out:0:240}" - exit 1 fi # ══════════════════════════════════════════════════════════════════════ @@ -618,19 +681,24 @@ fi # Deploy managed skill fixture into sandbox and verify one agent turn returns token. section "Phase 5d: Skill agent verification (inject + token)" -info "Injecting skill-smoke-fixture into sandbox '${SANDBOX_NAME}'..." -if ! SANDBOX_NAME="$SANDBOX_NAME" SKILL_ID="skill-smoke-fixture" bash "$E2E_DIR/e2e-cloud-experimental/features/skill/add-sandbox-skill.sh"; then - fail "Phase 5d: failed to inject/query skill-smoke-fixture" - exit 1 -fi -pass "Phase 5d: skill-smoke-fixture injected and queryable" +if ! e2e_cloud_experimental_phase_enabled phase5d; then + skip "Phase 5d: skipped by tag (phase5d — set E2E_CLOUD_EXPERIMENTAL_ONLY_TAGS / SKIP_TAGS)" +else + info "Injecting skill-smoke-fixture into sandbox '${SANDBOX_NAME}'..." + if ! SANDBOX_NAME="$SANDBOX_NAME" SKILL_ID="skill-smoke-fixture" bash "$E2E_DIR/e2e-cloud-experimental/features/skill/add-sandbox-skill.sh"; then + fail "Phase 5d: failed to inject/query skill-smoke-fixture" + exit 1 + fi + pass "Phase 5d: skill-smoke-fixture injected and queryable" + + info "Running one openclaw agent turn to verify skill token..." + if ! NVIDIA_API_KEY="$NVIDIA_API_KEY" SANDBOX_NAME="$SANDBOX_NAME" SKILL_ID="skill-smoke-fixture" bash "$E2E_DIR/e2e-cloud-experimental/features/skill/verify-sandbox-skill-via-agent.sh"; then + fail "Phase 5d: agent verification did not return skill token" + exit 1 + fi + pass "Phase 5d: agent returned SKILL_SMOKE_VERIFY_K9X2" -info "Running one openclaw agent turn to verify skill token..." -if ! NVIDIA_API_KEY="$NVIDIA_API_KEY" SANDBOX_NAME="$SANDBOX_NAME" SKILL_ID="skill-smoke-fixture" bash "$E2E_DIR/e2e-cloud-experimental/features/skill/verify-sandbox-skill-via-agent.sh"; then - fail "Phase 5d: agent verification did not return skill token" - exit 1 fi -pass "Phase 5d: agent returned SKILL_SMOKE_VERIFY_K9X2" # ══════════════════════════════════════════════════════════════════════ # Phase 5e: OpenClaw TUI smoke (nemoclaw connect → tui → message → Ctrl+C → exit) @@ -639,7 +707,9 @@ pass "Phase 5d: agent returned SKILL_SMOKE_VERIFY_K9X2" # e2e-cloud-experimental/openclaw-tui-in-sandbox.sh (that wrapper uses `interact` for humans). section "Phase 5e: OpenClaw TUI smoke (expect)" -if [ "${RUN_E2E_CLOUD_EXPERIMENTAL_TUI:-1}" = "0" ]; then +if ! e2e_cloud_experimental_phase_enabled phase5e; then + skip "Phase 5e: skipped by tag (phase5e — set E2E_CLOUD_EXPERIMENTAL_ONLY_TAGS / SKIP_TAGS)" +elif [ "${RUN_E2E_CLOUD_EXPERIMENTAL_TUI:-1}" = "0" ]; then skip "Phase 5e: skipped (RUN_E2E_CLOUD_EXPERIMENTAL_TUI=0)" elif ! command -v expect >/dev/null 2>&1; then skip "Phase 5e: expect not on PATH — install expect to run TUI smoke (e.g. apt install expect)" @@ -796,7 +866,9 @@ fi # ══════════════════════════════════════════════════════════════════════ section "Phase 5f: Documentation checks (check-docs.sh)" -if [ "${RUN_E2E_CLOUD_EXPERIMENTAL_SKIP_CHECK_DOCS:-0}" = "1" ]; then +if ! e2e_cloud_experimental_phase_enabled phase5f; then + skip "Phase 5f: skipped by tag (phase5f — set E2E_CLOUD_EXPERIMENTAL_ONLY_TAGS / SKIP_TAGS)" +elif [ "${RUN_E2E_CLOUD_EXPERIMENTAL_SKIP_CHECK_DOCS:-0}" = "1" ]; then skip "Phase 5f: check-docs skipped (RUN_E2E_CLOUD_EXPERIMENTAL_SKIP_CHECK_DOCS=1)" else info "check-docs.sh (default: curl unique http(s) links; CHECK_DOC_LINKS_REMOTE=0 to skip remote only)" @@ -814,50 +886,18 @@ fi # openshell sandbox delete + forward stop + gateway destroy. section "Phase 6: Final cleanup" -if [ "${RUN_E2E_CLOUD_EXPERIMENTAL_FROM_PHASE5:-0}" = "1" ] && [ "${RUN_E2E_CLOUD_EXPERIMENTAL_FROM_PHASE5_RUN_CLEANUP:-0}" != "1" ]; then +if ! e2e_cloud_experimental_phase_enabled phase6; then + skip "Phase 6: skipped by tag (phase6 — set E2E_CLOUD_EXPERIMENTAL_ONLY_TAGS / SKIP_TAGS)" +elif [ "${RUN_E2E_CLOUD_EXPERIMENTAL_FROM_PHASE5:-0}" = "1" ] && [ "${RUN_E2E_CLOUD_EXPERIMENTAL_FROM_PHASE5_RUN_CLEANUP:-0}" != "1" ]; then skip "Phase 6: final cleanup skipped (RUN_E2E_CLOUD_EXPERIMENTAL_FROM_PHASE5=1 — set FROM_PHASE5_RUN_CLEANUP=1 to destroy sandbox)" elif [ "${RUN_E2E_CLOUD_EXPERIMENTAL_SKIP_FINAL_CLEANUP:-${RUN_SCENARIO_A_SKIP_FINAL_CLEANUP:-}}" = "1" ]; then skip "Phase 6: final cleanup skipped (RUN_E2E_CLOUD_EXPERIMENTAL_SKIP_FINAL_CLEANUP=1)" else info "Removing sandbox '${SANDBOX_NAME}', port forward, and nemoclaw gateway..." - - if command -v nemoclaw >/dev/null 2>&1; then - nemoclaw "$SANDBOX_NAME" destroy --yes 2>/dev/null || true - fi - if command -v openshell >/dev/null 2>&1; then - openshell sandbox delete "$SANDBOX_NAME" 2>/dev/null || true - openshell forward stop 18789 2>/dev/null || true - openshell gateway destroy -g nemoclaw 2>/dev/null || true - fi - - if command -v openshell >/dev/null 2>&1; then - if openshell sandbox get "$SANDBOX_NAME" >/dev/null 2>&1; then - fail "openshell sandbox get '${SANDBOX_NAME}' still succeeds after cleanup" - exit 1 - fi - pass "openshell: sandbox '${SANDBOX_NAME}' no longer visible to sandbox get" - else - skip "openshell not on PATH — skipped sandbox get check after cleanup" - fi - - if command -v nemoclaw >/dev/null 2>&1; then - set +e - list_out=$(nemoclaw list 2>&1) - list_rc=$? - set -uo pipefail - if [ "$list_rc" -eq 0 ]; then - if echo "$list_out" | grep -Fq " ${SANDBOX_NAME}"; then - fail "nemoclaw list still lists '${SANDBOX_NAME}' after destroy" - exit 1 - fi - pass "nemoclaw list: '${SANDBOX_NAME}' removed from registry" - else - skip "nemoclaw list failed after cleanup — could not verify registry (exit $list_rc)" - fi - else - skip "nemoclaw not on PATH — skipped list check after cleanup" + if ! SANDBOX_NAME="$SANDBOX_NAME" bash "${E2E_DIR}/e2e-cloud-experimental/cleanup.sh" --verify; then + fail "Phase 6: final cleanup or verification failed" + exit 1 fi - pass "Phase 6: final cleanup complete" fi diff --git a/test/e2e/test-full-e2e.sh b/test/e2e/test-full-e2e.sh index b0c418558..275a6ed16 100755 --- a/test/e2e/test-full-e2e.sh +++ b/test/e2e/test-full-e2e.sh @@ -14,13 +14,14 @@ # - Network access to integrate.api.nvidia.com # # Environment variables: -# NEMOCLAW_NON_INTERACTIVE=1 — required (enables non-interactive install + onboard) -# NEMOCLAW_SANDBOX_NAME — sandbox name (default: e2e-nightly) -# NEMOCLAW_RECREATE_SANDBOX=1 — recreate sandbox if it exists from a previous run -# NVIDIA_API_KEY — required for NVIDIA Endpoints inference +# NEMOCLAW_NON_INTERACTIVE=1 — required (enables non-interactive install + onboard) +# NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 — required for non-interactive install/onboard +# NEMOCLAW_SANDBOX_NAME — sandbox name (default: e2e-nightly) +# NEMOCLAW_RECREATE_SANDBOX=1 — recreate sandbox if it exists from a previous run +# NVIDIA_API_KEY — required for NVIDIA Endpoints inference # # Usage: -# NEMOCLAW_NON_INTERACTIVE=1 NVIDIA_API_KEY=nvapi-... bash test/e2e/test-full-e2e.sh +# NEMOCLAW_NON_INTERACTIVE=1 NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 NVIDIA_API_KEY=nvapi-... bash test/e2e/test-full-e2e.sh # # See: https://github.com/NVIDIA/NemoClaw/issues/71 @@ -125,6 +126,11 @@ if [ "${NEMOCLAW_NON_INTERACTIVE:-}" != "1" ]; then exit 1 fi +if [ "${NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE:-}" != "1" ]; then + fail "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 is required for non-interactive install" + exit 1 +fi + # ══════════════════════════════════════════════════════════════════ # Phase 2: Install nemoclaw (non-interactive mode) # ══════════════════════════════════════════════════════════════════ @@ -341,9 +347,12 @@ section "Phase 6: Cleanup" nemoclaw "$SANDBOX_NAME" destroy --yes 2>&1 | tail -3 || true openshell gateway destroy -g nemoclaw 2>/dev/null || true -list_after=$(nemoclaw list 2>&1) -if grep -Fq -- "$SANDBOX_NAME" <<<"$list_after"; then - fail "Sandbox ${SANDBOX_NAME} still in list after destroy" +# Verify against the registry file directly. `nemoclaw list` triggers +# gateway recovery which can restart a destroyed gateway and re-import stale +# sandbox entries — that's a separate issue (#TBD), so avoid it here. +registry_file="${HOME}/.nemoclaw/sandboxes.json" +if [ -f "$registry_file" ] && grep -Fq "\"${SANDBOX_NAME}\"" "$registry_file"; then + fail "Sandbox ${SANDBOX_NAME} still in registry after destroy" else pass "Sandbox ${SANDBOX_NAME} removed" fi diff --git a/test/e2e/test-gpu-e2e.sh b/test/e2e/test-gpu-e2e.sh new file mode 100755 index 000000000..6d034b964 --- /dev/null +++ b/test/e2e/test-gpu-e2e.sh @@ -0,0 +1,454 @@ +#!/bin/bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# GPU E2E: Ollama local inference — follows the real user flow. +# +# Mirrors what a user with a GPU would actually do: +# 1. Install Ollama binary +# 2. Run the NemoClaw installer with NEMOCLAW_PROVIDER=ollama +# 3. Onboard starts Ollama (OLLAMA_HOST=0.0.0.0:11434), pulls model, creates sandbox +# 4. Verify inference works through the sandbox +# 5. Destroy + uninstall +# +# The test does NOT pre-start Ollama or pre-pull models — onboard handles that. +# +# Prerequisites: +# - NVIDIA GPU with drivers (nvidia-smi works) +# - Docker +# - NEMOCLAW_NON_INTERACTIVE=1 +# - NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 +# - Internet access (ollama.com for install, registry.ollama.ai for model pull) +# - No existing Ollama service on port 11434 (ephemeral runners are ideal) +# +# Environment variables: +# NEMOCLAW_NON_INTERACTIVE=1 — required +# NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 — required for non-interactive install/onboard +# NEMOCLAW_SANDBOX_NAME — sandbox name (default: e2e-gpu-ollama) +# NEMOCLAW_RECREATE_SANDBOX=1 — recreate sandbox if it exists +# NEMOCLAW_MODEL — model for onboard (default: auto-selected by onboard) +# SKIP_UNINSTALL — set to 1 to skip uninstall (debugging) +# +# Usage: +# NEMOCLAW_NON_INTERACTIVE=1 NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 bash test/e2e/test-gpu-e2e.sh + +set -uo pipefail + +PASS=0 +FAIL=0 +SKIP=0 +TOTAL=0 + +pass() { + ((PASS++)) + ((TOTAL++)) + printf '\033[32m PASS: %s\033[0m\n' "$1" +} +fail() { + ((FAIL++)) + ((TOTAL++)) + printf '\033[31m FAIL: %s\033[0m\n' "$1" +} +skip() { + ((SKIP++)) + ((TOTAL++)) + printf '\033[33m SKIP: %s\033[0m\n' "$1" +} +section() { + echo "" + printf '\033[1;36m=== %s ===\033[0m\n' "$1" +} +info() { printf '\033[1;34m [info]\033[0m %s\n' "$1"; } + +# Parse chat completion response — handles both content and reasoning_content +parse_chat_content() { + python3 -c " +import json, sys +try: + r = json.load(sys.stdin) + c = r['choices'][0]['message'] + # Reasoning models (nemotron-3-nano) may put output in 'reasoning' or + # 'reasoning_content' instead of 'content'. Check all fields. + content = c.get('content') or c.get('reasoning_content') or c.get('reasoning') or '' + print(content.strip()) +except Exception as e: + print(f'PARSE_ERROR: {e}', file=sys.stderr) + sys.exit(1) +" +} + +# Determine repo root +if [ -d /workspace ] && [ -f /workspace/install.sh ]; then + REPO="/workspace" +elif [ -f "$(cd "$(dirname "$0")/../.." && pwd)/install.sh" ]; then + REPO="$(cd "$(dirname "$0")/../.." && pwd)" +else + echo "ERROR: Cannot find repo root." + exit 1 +fi + +SANDBOX_NAME="${NEMOCLAW_SANDBOX_NAME:-e2e-gpu-ollama}" +TEST_LOG="/tmp/nemoclaw-gpu-e2e-test.log" +INSTALL_LOG="/tmp/nemoclaw-gpu-e2e-install.log" + +# Enforce Ollama provider — this script only tests local GPU inference. +export NEMOCLAW_PROVIDER="${NEMOCLAW_PROVIDER:-ollama}" +if [ "$NEMOCLAW_PROVIDER" != "ollama" ]; then + echo "ERROR: NEMOCLAW_PROVIDER must be 'ollama' for GPU E2E (got: $NEMOCLAW_PROVIDER)" + exit 1 +fi + +exec > >(tee -a "$TEST_LOG") 2>&1 + +# Best-effort cleanup on any exit (prevents dirty state on reused runners) +# shellcheck disable=SC2329 # invoked via trap +cleanup() { + info "Running exit cleanup..." + if command -v nemoclaw >/dev/null 2>&1; then + nemoclaw "$SANDBOX_NAME" destroy --yes 2>/dev/null || true + fi + if command -v openshell >/dev/null 2>&1; then + openshell sandbox delete "$SANDBOX_NAME" 2>/dev/null || true + openshell gateway destroy -g nemoclaw 2>/dev/null || true + fi + pkill -f "ollama serve" 2>/dev/null || true +} +trap cleanup EXIT + +# ══════════════════════════════════════════════════════════════════ +# Phase 0: Pre-cleanup +# ══════════════════════════════════════════════════════════════════ +section "Phase 0: Pre-cleanup" +info "Destroying any leftover sandbox/gateway from previous runs..." +if command -v nemoclaw >/dev/null 2>&1; then + nemoclaw "$SANDBOX_NAME" destroy --yes 2>/dev/null || true +fi +if command -v openshell >/dev/null 2>&1; then + openshell sandbox delete "$SANDBOX_NAME" 2>/dev/null || true + openshell gateway destroy -g nemoclaw 2>/dev/null || true +fi +pass "Pre-cleanup complete" + +# ══════════════════════════════════════════════════════════════════ +# Phase 1: Prerequisites +# ══════════════════════════════════════════════════════════════════ +section "Phase 1: Prerequisites" + +if docker info >/dev/null 2>&1; then + pass "Docker is running" +else + fail "Docker is not running — cannot continue" + exit 1 +fi + +if nvidia-smi >/dev/null 2>&1; then + VRAM_MB=$(nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits 2>/dev/null | head -1) + pass "nvidia-smi works (GPU VRAM: ${VRAM_MB:-unknown} MB)" +else + fail "nvidia-smi failed — no NVIDIA GPU available" + exit 1 +fi + +if [ "${NEMOCLAW_NON_INTERACTIVE:-}" != "1" ]; then + fail "NEMOCLAW_NON_INTERACTIVE=1 is required" + exit 1 +fi + +if [ "${NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE:-}" != "1" ]; then + fail "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 is required for non-interactive install" + exit 1 +fi + +# Verify port 11434 is free (onboard needs to start Ollama on 0.0.0.0:11434) +if curl -sf http://localhost:11434/api/tags >/dev/null 2>&1; then + info "WARNING: Something is already listening on port 11434." + info "Onboard may not be able to start Ollama on 0.0.0.0:11434." + info "On ephemeral runners this should not happen." + # Don't fail — onboard will detect the running Ollama and use it. + # The container reachability check in onboard will catch 127.0.0.1 issues. +fi + +# ══════════════════════════════════════════════════════════════════ +# Phase 2: Install Ollama binary +# ══════════════════════════════════════════════════════════════════ +section "Phase 2: Install Ollama binary" + +# Only install the binary — do NOT start Ollama or pull models. +# The nemoclaw onboard flow handles startup and model pull itself. +if command -v ollama >/dev/null 2>&1; then + pass "Ollama already installed: $(ollama --version 2>/dev/null || echo unknown)" +else + info "Installing Ollama..." + if curl -fsSL https://ollama.com/install.sh | sh 2>&1; then + pass "Ollama installed: $(ollama --version 2>/dev/null || echo unknown)" + else + fail "Ollama installation failed" + exit 1 + fi +fi + +# If the Ollama installer started a system service, stop it so onboard +# can start Ollama with OLLAMA_HOST=0.0.0.0:11434 (required for containers). +# This needs the ollama process to be owned by our user, or systemctl access. +if curl -sf http://localhost:11434/api/tags >/dev/null 2>&1; then + info "Ollama service is running — attempting to stop for clean onboard..." + # Try systemctl first (works if user has permissions) + systemctl --user stop ollama 2>/dev/null || true + systemctl stop ollama 2>/dev/null || true + # Try direct kill (works if process is owned by our user) + pkill -f "ollama serve" 2>/dev/null || true + sleep 2 + + if curl -sf http://localhost:11434/api/tags >/dev/null 2>&1; then + info "Could not stop existing Ollama — onboard will use it as-is" + else + pass "Existing Ollama stopped — port 11434 is free for onboard" + fi +fi + +# ══════════════════════════════════════════════════════════════════ +# Phase 3: Install NemoClaw and onboard with Ollama +# ══════════════════════════════════════════════════════════════════ +section "Phase 3: Install NemoClaw and onboard with Ollama" + +cd "$REPO" || { + fail "Could not cd to repo root: $REPO" + exit 1 +} + +info "Running install.sh --non-interactive with NEMOCLAW_PROVIDER=ollama..." +info "Onboard will start Ollama, pull the model, and create the sandbox." + +bash install.sh --non-interactive >"$INSTALL_LOG" 2>&1 & +install_pid=$! +tail -f "$INSTALL_LOG" --pid=$install_pid 2>/dev/null & +tail_pid=$! +wait $install_pid +install_exit=$? +kill $tail_pid 2>/dev/null || true +wait $tail_pid 2>/dev/null || true + +# Source shell profile to pick up nvm/PATH changes +if [ -f "$HOME/.bashrc" ]; then + source "$HOME/.bashrc" 2>/dev/null || true +fi +export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" +[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" +if [ -d "$HOME/.local/bin" ] && [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then + export PATH="$HOME/.local/bin:$PATH" +fi + +if [ $install_exit -eq 0 ]; then + pass "install.sh completed (exit 0)" +else + fail "install.sh failed (exit $install_exit)" + info "Last 30 lines of install log:" + tail -30 "$INSTALL_LOG" + exit 1 +fi + +if command -v nemoclaw >/dev/null 2>&1; then + pass "nemoclaw on PATH: $(command -v nemoclaw)" +else + fail "nemoclaw not found on PATH after install" + exit 1 +fi + +# ══════════════════════════════════════════════════════════════════ +# Phase 4: Verify Ollama-based onboard +# ══════════════════════════════════════════════════════════════════ +section "Phase 4: Verify Ollama-based onboard" + +# 4a: Sandbox exists +if list_output=$(nemoclaw list 2>&1); then + if echo "$list_output" | grep -Fq -- "$SANDBOX_NAME"; then + pass "nemoclaw list contains '${SANDBOX_NAME}'" + else + fail "nemoclaw list does not contain '${SANDBOX_NAME}'" + fi +else + fail "nemoclaw list failed: ${list_output:0:200}" +fi + +# 4b: Status ok +if nemoclaw "$SANDBOX_NAME" status >/dev/null 2>&1; then + pass "nemoclaw ${SANDBOX_NAME} status exits 0" +else + fail "nemoclaw ${SANDBOX_NAME} status failed" +fi + +# 4c: Inference provider is ollama-local +if inf_check=$(openshell inference get 2>&1); then + if echo "$inf_check" | grep -qi "ollama"; then + pass "Inference provider is Ollama-based" + else + fail "Inference provider is not ollama — got: ${inf_check:0:200}" + fi +else + fail "openshell inference get failed: ${inf_check:0:200}" +fi + +# 4d: Ollama is running and reachable +if curl -sf http://localhost:11434/api/tags >/dev/null 2>&1; then + pass "Ollama running on localhost:11434 (started by onboard)" +else + fail "Ollama not running — onboard should have started it" +fi + +# ══════════════════════════════════════════════════════════════════ +# Phase 5: Local inference through sandbox +# ══════════════════════════════════════════════════════════════════ +section "Phase 5: Local inference through sandbox" + +# Determine the model to test. Prefer NEMOCLAW_MODEL (set by workflow), then +# fall back to querying Ollama's /api/tags (handles auto-selection by onboard). +CONFIGURED_MODEL="${NEMOCLAW_MODEL:-}" +if [ -n "$CONFIGURED_MODEL" ]; then + # Verify the expected model is actually available in Ollama + if curl -sf http://localhost:11434/api/tags 2>/dev/null \ + | python3 -c "import json,sys; m=[x['name'] for x in json.load(sys.stdin).get('models',[])]; sys.exit(0 if '$CONFIGURED_MODEL' in m or any('$CONFIGURED_MODEL' in x for x in m) else 1)" 2>/dev/null; then + info "Using NEMOCLAW_MODEL: $CONFIGURED_MODEL (confirmed in Ollama)" + else + info "NEMOCLAW_MODEL=$CONFIGURED_MODEL not found in Ollama tags — querying available models" + CONFIGURED_MODEL="" + fi +fi +if [ -z "$CONFIGURED_MODEL" ]; then + CONFIGURED_MODEL=$(curl -sf http://localhost:11434/api/tags 2>/dev/null \ + | python3 -c "import json,sys; m=json.load(sys.stdin).get('models',[]); print(m[0]['name'] if m else '')" 2>/dev/null || echo "") + if [ -n "$CONFIGURED_MODEL" ]; then + info "Auto-detected Ollama model: $CONFIGURED_MODEL" + else + fail "No models found in Ollama" + fi +fi + +# 5a: Direct Ollama inference (host-side, OpenAI-compatible) +info "[LOCAL] Direct Ollama test → localhost:11434/v1/chat/completions..." +direct_response=$(curl -s --max-time 120 \ + -X POST http://localhost:11434/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d "{ + \"model\": \"$CONFIGURED_MODEL\", + \"messages\": [{\"role\": \"user\", \"content\": \"Reply with exactly one word: PONG\"}], + \"max_tokens\": 200 + }" 2>/dev/null) || true + +if [ -n "$direct_response" ]; then + direct_content=$(echo "$direct_response" | parse_chat_content 2>/dev/null) || true + if echo "$direct_content" | grep -qi "PONG"; then + pass "[LOCAL] Direct Ollama: model responded with PONG" + else + fail "[LOCAL] Direct Ollama: expected PONG, got: ${direct_content:0:200}" + fi +else + fail "[LOCAL] Direct Ollama: empty response" +fi + +# 5b: Inference through sandbox → openshell gateway → host.openshell.internal:11434 → Ollama +info "[LOCAL] Sandbox inference test → sandbox → gateway → Ollama on GPU..." +ssh_config="$(mktemp)" +sandbox_response="" + +if openshell sandbox ssh-config "$SANDBOX_NAME" >"$ssh_config" 2>/dev/null; then + TIMEOUT_CMD="" + command -v timeout >/dev/null 2>&1 && TIMEOUT_CMD="timeout 120" + sandbox_response=$($TIMEOUT_CMD ssh -F "$ssh_config" \ + -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null \ + -o ConnectTimeout=10 \ + -o LogLevel=ERROR \ + "openshell-${SANDBOX_NAME}" \ + "curl -s --max-time 90 https://inference.local/v1/chat/completions \ + -H 'Content-Type: application/json' \ + -d '{\"model\":\"$CONFIGURED_MODEL\",\"messages\":[{\"role\":\"user\",\"content\":\"Reply with exactly one word: PONG\"}],\"max_tokens\":200}'" \ + 2>&1) || true +else + fail "openshell sandbox ssh-config failed" +fi +rm -f "$ssh_config" + +if [ -n "$sandbox_response" ]; then + sandbox_content=$(echo "$sandbox_response" | parse_chat_content 2>/dev/null) || true + if echo "$sandbox_content" | grep -qi "PONG"; then + pass "[LOCAL] Sandbox inference: Ollama responded through sandbox" + info "Full path proven: sandbox → openshell gateway → host.openshell.internal:11434 → Ollama GPU" + else + fail "[LOCAL] Sandbox inference: expected PONG, got: ${sandbox_content:0:200}" + fi +else + fail "[LOCAL] Sandbox inference: no response from inference.local inside sandbox" +fi + +# ══════════════════════════════════════════════════════════════════ +# Phase 6: Destroy and uninstall +# ══════════════════════════════════════════════════════════════════ +section "Phase 6: Destroy and uninstall" + +# 6a: Destroy sandbox +info "Destroying sandbox ${SANDBOX_NAME}..." +nemoclaw "$SANDBOX_NAME" destroy --yes 2>&1 | tail -5 || true + +# Verify against the registry file directly. `nemoclaw list` triggers +# gateway recovery which can restart a destroyed gateway and re-import stale +# sandbox entries — that's a separate issue (#TBD), so avoid it here. +registry_file="${HOME}/.nemoclaw/sandboxes.json" +if [ -f "$registry_file" ] && grep -Fq "\"${SANDBOX_NAME}\"" "$registry_file"; then + fail "Sandbox ${SANDBOX_NAME} still in registry after destroy" +else + pass "Sandbox ${SANDBOX_NAME} removed from registry" +fi + +openshell gateway destroy -g nemoclaw 2>/dev/null || true + +# 6b: Uninstall with --delete-models (Ollama-specific flag) +if [ "${SKIP_UNINSTALL:-}" = "1" ]; then + skip "Uninstall skipped (SKIP_UNINSTALL=1)" +else + info "Running uninstall.sh --yes --delete-models..." + if bash "$REPO/uninstall.sh" --yes --delete-models 2>&1 | tail -20; then + pass "uninstall.sh --delete-models completed" + else + fail "uninstall.sh failed" + fi + + if [ -d "$HOME/.nemoclaw" ]; then + fail "$HOME/.nemoclaw directory still exists after uninstall" + else + pass "$HOME/.nemoclaw removed" + fi +fi + +# 6c: Stop Ollama (started by onboard) +info "Stopping Ollama..." +pkill -f "ollama serve" 2>/dev/null || true +pass "Cleanup complete" + +# ══════════════════════════════════════════════════════════════════ +# Summary +# ══════════════════════════════════════════════════════════════════ +echo "" +echo "========================================" +echo " GPU E2E Results (Ollama Local Inference):" +echo " Passed: $PASS" +echo " Failed: $FAIL" +echo " Skipped: $SKIP" +echo " Total: $TOTAL" +echo "========================================" +echo "" +echo " What this tested (real user flow):" +echo " - GPU detection (nvidia-smi)" +echo " - Ollama binary install" +echo " - install.sh --non-interactive with NEMOCLAW_PROVIDER=ollama" +echo " - Onboard: starts Ollama, pulls model, creates sandbox" +echo " - Local inference: direct + sandbox → gateway → Ollama on GPU" +echo " - Destroy + uninstall --delete-models" +echo "" + +if [ "$FAIL" -eq 0 ]; then + printf '\n\033[1;32m GPU E2E PASSED — Ollama local inference verified end-to-end.\033[0m\n' + exit 0 +else + printf '\n\033[1;31m %d test(s) failed.\033[0m\n' "$FAIL" + exit 1 +fi diff --git a/test/e2e/test-onboard-repair.sh b/test/e2e/test-onboard-repair.sh new file mode 100755 index 000000000..199c8b74c --- /dev/null +++ b/test/e2e/test-onboard-repair.sh @@ -0,0 +1,335 @@ +#!/bin/bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# E2E: resume repair and invalidation behavior. +# +# Regression coverage for issue #446. +# Validates that: +# 1. Resume recreates a missing recorded sandbox instead of assuming it still exists. +# 2. Resume rejects a different requested sandbox name on the same host. +# 3. Resume rejects explicit provider/model changes that conflict with recorded state. +# +# Prerequisites: +# - Docker running +# - openshell CLI installed +# - Node.js available +# - NVIDIA_API_KEY set to a valid nvapi-* key before starting the test +# +# Usage: +# NVIDIA_API_KEY=nvapi-... bash test/e2e/test-onboard-repair.sh + +set -uo pipefail + +PASS=0 +FAIL=0 +SKIP=0 +TOTAL=0 + +pass() { + ((PASS++)) + ((TOTAL++)) + printf '\033[32m PASS: %s\033[0m\n' "$1" +} +fail() { + ((FAIL++)) + ((TOTAL++)) + printf '\033[31m FAIL: %s\033[0m\n' "$1" +} +skip() { + ((SKIP++)) + ((TOTAL++)) + printf '\033[33m SKIP: %s\033[0m\n' "$1" +} +section() { + echo "" + printf '\033[1;36m=== %s ===\033[0m\n' "$1" +} +info() { printf '\033[1;34m [info]\033[0m %s\n' "$1"; } + +if [ -d /workspace ] && [ -f /workspace/install.sh ]; then + REPO="/workspace" +elif [ -f "$(cd "$(dirname "$0")/../.." && pwd)/install.sh" ]; then + REPO="$(cd "$(dirname "$0")/../.." && pwd)" +else + echo "ERROR: Cannot find repo root." + exit 1 +fi + +run_nemoclaw() { + node "$REPO/bin/nemoclaw.js" "$@" +} + +SANDBOX_NAME="${NEMOCLAW_SANDBOX_NAME:-e2e-repair}" +OTHER_SANDBOX_NAME="${NEMOCLAW_OTHER_SANDBOX_NAME:-e2e-other}" +SESSION_FILE="$HOME/.nemoclaw/onboard-session.json" +RESTORE_API_KEY="${NVIDIA_API_KEY:-}" + +# ══════════════════════════════════════════════════════════════════ +# Phase 0: Pre-cleanup +# ══════════════════════════════════════════════════════════════════ +section "Phase 0: Pre-cleanup" +info "Destroying any leftover sandbox/gateway from previous runs..." +run_nemoclaw "$SANDBOX_NAME" destroy 2>/dev/null || true +run_nemoclaw "$OTHER_SANDBOX_NAME" destroy 2>/dev/null || true +openshell sandbox delete "$SANDBOX_NAME" 2>/dev/null || true +openshell sandbox delete "$OTHER_SANDBOX_NAME" 2>/dev/null || true +openshell forward stop 18789 2>/dev/null || true +openshell gateway destroy -g nemoclaw 2>/dev/null || true +rm -f "$SESSION_FILE" +pass "Pre-cleanup complete" + +# ══════════════════════════════════════════════════════════════════ +# Phase 1: Prerequisites +# ══════════════════════════════════════════════════════════════════ +section "Phase 1: Prerequisites" + +if docker info >/dev/null 2>&1; then + pass "Docker is running" +else + fail "Docker is not running — cannot continue" + exit 1 +fi + +if command -v openshell >/dev/null 2>&1; then + pass "openshell CLI installed" +else + fail "openshell CLI not found — cannot continue" + exit 1 +fi + +if command -v node >/dev/null 2>&1; then + pass "Node.js available" +else + fail "Node.js not found — cannot continue" + exit 1 +fi + +if [[ -n "$RESTORE_API_KEY" && "$RESTORE_API_KEY" == nvapi-* ]]; then + pass "NVIDIA_API_KEY is set (starts with nvapi-)" +else + fail "NVIDIA_API_KEY not set or invalid — required for resume completion" + exit 1 +fi + +node -e ' +const { saveCredential } = require(process.argv[1]); +saveCredential("NVIDIA_API_KEY", process.argv[2]); +' "$REPO/bin/lib/credentials.js" "$RESTORE_API_KEY" +pass "Stored NVIDIA_API_KEY in ~/.nemoclaw/credentials.json for resume hydration" + +# ══════════════════════════════════════════════════════════════════ +# Phase 2: Create interrupted resumable state +# ══════════════════════════════════════════════════════════════════ +section "Phase 2: Create interrupted state" +info "Running onboard with an invalid policy mode to create resumable state..." + +FIRST_LOG="$(mktemp)" +NEMOCLAW_NON_INTERACTIVE=1 \ + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 \ + NEMOCLAW_SANDBOX_NAME="$SANDBOX_NAME" \ + NEMOCLAW_RECREATE_SANDBOX=1 \ + NEMOCLAW_POLICY_MODE=invalid \ + node "$REPO/bin/nemoclaw.js" onboard --non-interactive >"$FIRST_LOG" 2>&1 +first_exit=$? +first_output="$(cat "$FIRST_LOG")" +rm -f "$FIRST_LOG" + +if [ $first_exit -eq 1 ]; then + pass "First onboard exited 1 (expected interrupted run)" +else + fail "First onboard exited $first_exit (expected 1)" + echo "$first_output" + exit 1 +fi + +if [ -f "$SESSION_FILE" ]; then + pass "Onboard session file created" +else + fail "Onboard session file missing after interrupted run" +fi + +if echo "$first_output" | grep -q "Unsupported NEMOCLAW_POLICY_MODE: invalid"; then + pass "First run failed at policy setup as intended" +else + fail "First run did not fail at the expected policy step" +fi + +if openshell sandbox get "$SANDBOX_NAME" >/dev/null 2>&1; then + pass "Sandbox '$SANDBOX_NAME' exists after interrupted run" +else + fail "Sandbox '$SANDBOX_NAME' not found after interrupted run" +fi + +# ══════════════════════════════════════════════════════════════════ +# Phase 3: Repair missing sandbox on resume +# ══════════════════════════════════════════════════════════════════ +section "Phase 3: Repair missing sandbox" +info "Deleting the recorded sandbox under the session, then resuming..." + +openshell sandbox delete "$SANDBOX_NAME" >/dev/null 2>&1 || true +openshell forward stop 18789 >/dev/null 2>&1 || true + +if openshell sandbox get "$SANDBOX_NAME" >/dev/null 2>&1; then + fail "Sandbox '$SANDBOX_NAME' still exists after forced deletion" +else + pass "Sandbox '$SANDBOX_NAME' removed to simulate stale recorded state" +fi + +REPAIR_LOG="$(mktemp)" +env -u NVIDIA_API_KEY \ + NEMOCLAW_NON_INTERACTIVE=1 \ + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 \ + NEMOCLAW_SANDBOX_NAME="$SANDBOX_NAME" \ + NEMOCLAW_POLICY_MODE=skip \ + node "$REPO/bin/nemoclaw.js" onboard --resume --non-interactive >"$REPAIR_LOG" 2>&1 +repair_exit=$? +repair_output="$(cat "$REPAIR_LOG")" +rm -f "$REPAIR_LOG" + +if [ $repair_exit -eq 0 ]; then + pass "Resume completed after repairing missing sandbox" +else + fail "Resume exited $repair_exit during missing-sandbox repair" + echo "$repair_output" + exit 1 +fi + +if echo "$repair_output" | grep -q "\[resume\] Skipping preflight (cached)"; then + pass "Repair resume skipped preflight" +else + fail "Repair resume did not skip preflight" +fi + +if echo "$repair_output" | grep -q "\[resume\] Skipping gateway (running)"; then + pass "Repair resume skipped gateway" +else + fail "Repair resume did not skip gateway" +fi + +if echo "$repair_output" | grep -q "\[resume\] Recorded sandbox state is unavailable; recreating it."; then + pass "Repair resume detected missing sandbox" +else + fail "Repair resume did not report missing sandbox recreation" +fi + +if echo "$repair_output" | grep -q "\[5/7\] Creating sandbox"; then + pass "Repair resume recreated sandbox" +else + fail "Repair resume did not rerun sandbox creation" +fi + +if run_nemoclaw "$SANDBOX_NAME" status >/dev/null 2>&1; then + pass "Repaired sandbox '$SANDBOX_NAME' is manageable" +else + fail "Repaired sandbox '$SANDBOX_NAME' status failed" +fi + +# ══════════════════════════════════════════════════════════════════ +# Phase 4: Reject conflicting sandbox +# ══════════════════════════════════════════════════════════════════ +section "Phase 4: Reject conflicting sandbox" +info "Attempting resume with a different sandbox name..." + +SANDBOX_CONFLICT_LOG="$(mktemp)" +env -u NVIDIA_API_KEY \ + NEMOCLAW_NON_INTERACTIVE=1 \ + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 \ + NEMOCLAW_SANDBOX_NAME="$OTHER_SANDBOX_NAME" \ + NEMOCLAW_POLICY_MODE=skip \ + node "$REPO/bin/nemoclaw.js" onboard --resume --non-interactive >"$SANDBOX_CONFLICT_LOG" 2>&1 +sandbox_conflict_exit=$? +sandbox_conflict_output="$(cat "$SANDBOX_CONFLICT_LOG")" +rm -f "$SANDBOX_CONFLICT_LOG" + +if [ $sandbox_conflict_exit -eq 1 ]; then + pass "Resume rejected conflicting sandbox name" +else + fail "Resume exited $sandbox_conflict_exit for conflicting sandbox (expected 1)" +fi + +if echo "$sandbox_conflict_output" | grep -q "Resumable state belongs to sandbox '${SANDBOX_NAME}', not '${OTHER_SANDBOX_NAME}'."; then + pass "Conflicting sandbox message is explicit" +else + fail "Conflicting sandbox message missing or incorrect" +fi + +# ══════════════════════════════════════════════════════════════════ +# Phase 5: Reject conflicting provider/model +# ══════════════════════════════════════════════════════════════════ +section "Phase 5: Reject conflicting provider and model" +info "Attempting resume with conflicting provider/model inputs..." + +PROVIDER_CONFLICT_LOG="$(mktemp)" +env -u NVIDIA_API_KEY \ + NEMOCLAW_NON_INTERACTIVE=1 \ + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 \ + NEMOCLAW_SANDBOX_NAME="$SANDBOX_NAME" \ + NEMOCLAW_PROVIDER=openai \ + NEMOCLAW_MODEL=gpt-5.4 \ + NEMOCLAW_POLICY_MODE=skip \ + node "$REPO/bin/nemoclaw.js" onboard --resume --non-interactive >"$PROVIDER_CONFLICT_LOG" 2>&1 +provider_conflict_exit=$? +provider_conflict_output="$(cat "$PROVIDER_CONFLICT_LOG")" +rm -f "$PROVIDER_CONFLICT_LOG" + +if [ $provider_conflict_exit -eq 1 ]; then + pass "Resume rejected conflicting provider/model" +else + fail "Resume exited $provider_conflict_exit for conflicting provider/model (expected 1)" +fi + +if echo "$provider_conflict_output" | grep -Eq "Resumable state recorded provider '.*', not '.*'\."; then + pass "Conflicting provider message is explicit" +else + fail "Conflicting provider message missing or incorrect" +fi + +if echo "$provider_conflict_output" | grep -Eq "Resumable state recorded model '.*', not 'gpt-5.4'\."; then + pass "Conflicting model message is explicit" +else + fail "Conflicting model message missing or incorrect" +fi + +# ══════════════════════════════════════════════════════════════════ +# Phase 6: Final cleanup +# ══════════════════════════════════════════════════════════════════ +section "Phase 6: Final cleanup" + +run_nemoclaw "$SANDBOX_NAME" destroy 2>/dev/null || true +run_nemoclaw "$OTHER_SANDBOX_NAME" destroy 2>/dev/null || true +openshell sandbox delete "$SANDBOX_NAME" 2>/dev/null || true +openshell sandbox delete "$OTHER_SANDBOX_NAME" 2>/dev/null || true +openshell forward stop 18789 2>/dev/null || true +openshell gateway destroy -g nemoclaw 2>/dev/null || true +rm -f "$SESSION_FILE" + +if openshell sandbox get "$SANDBOX_NAME" >/dev/null 2>&1; then + fail "Sandbox '$SANDBOX_NAME' still exists after cleanup" +else + pass "Sandbox '$SANDBOX_NAME' cleaned up" +fi + +if [ -f "$SESSION_FILE" ]; then + fail "Onboard session file still exists after cleanup" +else + pass "Onboard session file cleaned up" +fi + +pass "Final cleanup complete" + +# ══════════════════════════════════════════════════════════════════ +# Summary +# ══════════════════════════════════════════════════════════════════ +echo "" +echo "========================================" +echo " PASS: $PASS" +echo " FAIL: $FAIL" +echo " SKIP: $SKIP" +echo " TOTAL: $TOTAL" +echo "========================================" +echo "" + +if [ $FAIL -ne 0 ]; then + exit 1 +fi diff --git a/test/e2e/test-onboard-resume.sh b/test/e2e/test-onboard-resume.sh new file mode 100755 index 000000000..6760aa093 --- /dev/null +++ b/test/e2e/test-onboard-resume.sh @@ -0,0 +1,341 @@ +#!/bin/bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# E2E: interrupted onboard -> resume -> verify completion. +# +# Regression test for issue #446. +# Validates that: +# 1. A non-interactive onboard run can fail after sandbox creation while leaving resumable state. +# 2. The onboard session file records the interrupted state safely. +# 3. `nemoclaw onboard --resume --non-interactive` skips cached preflight, +# gateway, and sandbox work, then completes by hydrating the stored credential. +# +# Prerequisites: +# - Docker running +# - openshell CLI installed +# - Node.js available +# - NVIDIA_API_KEY set to a valid nvapi-* key before starting the test +# +# Usage: +# NVIDIA_API_KEY=nvapi-... bash test/e2e/test-onboard-resume.sh + +set -uo pipefail + +if [ "${NEMOCLAW_E2E_NO_TIMEOUT:-0}" != "1" ]; then + TIMEOUT_SECONDS="${TIMEOUT_SECONDS:-600}" + TIMEOUT_BIN="" + if command -v timeout >/dev/null 2>&1; then + TIMEOUT_BIN="timeout" + elif command -v gtimeout >/dev/null 2>&1; then + TIMEOUT_BIN="gtimeout" + fi + + if [ -n "$TIMEOUT_BIN" ]; then + export NEMOCLAW_E2E_NO_TIMEOUT=1 + exec "$TIMEOUT_BIN" -s TERM "$TIMEOUT_SECONDS" "$0" "$@" + fi +fi + +PASS=0 +FAIL=0 +SKIP=0 +TOTAL=0 + +pass() { + ((PASS++)) + ((TOTAL++)) + printf '\033[32m PASS: %s\033[0m\n' "$1" +} +fail() { + ((FAIL++)) + ((TOTAL++)) + printf '\033[31m FAIL: %s\033[0m\n' "$1" +} +skip() { + ((SKIP++)) + ((TOTAL++)) + printf '\033[33m SKIP: %s\033[0m\n' "$1" +} +section() { + echo "" + printf '\033[1;36m=== %s ===\033[0m\n' "$1" +} +info() { printf '\033[1;34m [info]\033[0m %s\n' "$1"; } + +if [ -d /workspace ] && [ -f /workspace/install.sh ]; then + REPO="/workspace" +elif [ -f "$(cd "$(dirname "$0")/../.." && pwd)/install.sh" ]; then + REPO="$(cd "$(dirname "$0")/../.." && pwd)" +else + echo "ERROR: Cannot find repo root." + exit 1 +fi + +run_nemoclaw() { + node "$REPO/bin/nemoclaw.js" "$@" +} + +SANDBOX_NAME="${NEMOCLAW_SANDBOX_NAME:-e2e-resume}" +SESSION_FILE="$HOME/.nemoclaw/onboard-session.json" +REGISTRY="$HOME/.nemoclaw/sandboxes.json" +RESTORE_API_KEY="${NVIDIA_API_KEY:-}" + +# ══════════════════════════════════════════════════════════════════ +# Phase 0: Pre-cleanup +# ══════════════════════════════════════════════════════════════════ +section "Phase 0: Pre-cleanup" +info "Destroying any leftover sandbox/gateway from previous runs..." +run_nemoclaw "$SANDBOX_NAME" destroy 2>/dev/null || true +openshell sandbox delete "$SANDBOX_NAME" 2>/dev/null || true +openshell forward stop 18789 2>/dev/null || true +openshell gateway destroy -g nemoclaw 2>/dev/null || true +rm -f "$SESSION_FILE" +pass "Pre-cleanup complete" + +# ══════════════════════════════════════════════════════════════════ +# Phase 1: Prerequisites +# ══════════════════════════════════════════════════════════════════ +section "Phase 1: Prerequisites" + +if docker info >/dev/null 2>&1; then + pass "Docker is running" +else + fail "Docker is not running — cannot continue" + exit 1 +fi + +if command -v openshell >/dev/null 2>&1; then + pass "openshell CLI installed" +else + fail "openshell CLI not found — cannot continue" + exit 1 +fi + +if command -v node >/dev/null 2>&1; then + pass "Node.js available" +else + fail "Node.js not found — cannot continue" + exit 1 +fi + +if [[ -n "$RESTORE_API_KEY" && "$RESTORE_API_KEY" == nvapi-* ]]; then + pass "NVIDIA_API_KEY is set (starts with nvapi-)" +else + fail "NVIDIA_API_KEY not set or invalid — required for resume completion" + exit 1 +fi + +if curl -sf --max-time 10 https://integrate.api.nvidia.com/v1/models >/dev/null 2>&1; then + pass "Network access to integrate.api.nvidia.com" +else + fail "Cannot reach integrate.api.nvidia.com" + exit 1 +fi + +node -e ' +const { saveCredential } = require(process.argv[1]); +saveCredential("NVIDIA_API_KEY", process.argv[2]); +' "$REPO/bin/lib/credentials.js" "$RESTORE_API_KEY" +pass "Stored NVIDIA_API_KEY in ~/.nemoclaw/credentials.json for resume hydration" + +# ══════════════════════════════════════════════════════════════════ +# Phase 2: First onboard (forced failure after sandbox creation) +# ══════════════════════════════════════════════════════════════════ +section "Phase 2: First onboard (interrupted)" +info "Running onboard with an invalid policy mode to create resumable state..." + +FIRST_LOG="$(mktemp)" +NEMOCLAW_NON_INTERACTIVE=1 \ + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 \ + NEMOCLAW_SANDBOX_NAME="$SANDBOX_NAME" \ + NEMOCLAW_RECREATE_SANDBOX=1 \ + NEMOCLAW_POLICY_MODE=invalid \ + node "$REPO/bin/nemoclaw.js" onboard --non-interactive >"$FIRST_LOG" 2>&1 +first_exit=$? +first_output="$(cat "$FIRST_LOG")" +rm -f "$FIRST_LOG" + +if [ $first_exit -eq 1 ]; then + pass "First onboard exited 1 (expected interrupted run)" +else + fail "First onboard exited $first_exit (expected 1)" + echo "$first_output" + exit 1 +fi + +if echo "$first_output" | grep -q "Sandbox '${SANDBOX_NAME}' created"; then + pass "Sandbox '$SANDBOX_NAME' created before interruption" +else + fail "Sandbox creation not confirmed in first run output" +fi + +if echo "$first_output" | grep -q "Unsupported NEMOCLAW_POLICY_MODE: invalid"; then + pass "First run failed at policy setup as intended" +else + fail "First run did not fail at the expected policy step" +fi + +if openshell sandbox get "$SANDBOX_NAME" >/dev/null 2>&1; then + pass "Sandbox '$SANDBOX_NAME' exists after interrupted run" +else + fail "Sandbox '$SANDBOX_NAME' not found after interrupted run" +fi + +if [ -f "$SESSION_FILE" ]; then + pass "Onboard session file created" +else + fail "Onboard session file missing after interrupted run" +fi + +node -e ' +const fs = require("fs"); +const file = process.argv[1]; +const data = JSON.parse(fs.readFileSync(file, "utf8")); +if (data.status !== "failed") process.exit(1); +if (data.lastCompletedStep !== "openclaw") process.exit(2); +if (!data.failure || data.failure.step !== "policies") process.exit(3); +' "$SESSION_FILE" +case $? in + 0) pass "Session file recorded openclaw completion and policy failure" ;; + *) fail "Session file did not record the expected interrupted state" ;; +esac + +# ══════════════════════════════════════════════════════════════════ +# Phase 3: Resume and complete +# ══════════════════════════════════════════════════════════════════ +section "Phase 3: Resume" +info "Running onboard --resume with NVIDIA_API_KEY removed from env..." + +RESUME_LOG="$(mktemp)" +env -u NVIDIA_API_KEY \ + NEMOCLAW_NON_INTERACTIVE=1 \ + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 \ + NEMOCLAW_SANDBOX_NAME="$SANDBOX_NAME" \ + NEMOCLAW_POLICY_MODE=skip \ + node "$REPO/bin/nemoclaw.js" onboard --resume --non-interactive >"$RESUME_LOG" 2>&1 +resume_exit=$? +resume_output="$(cat "$RESUME_LOG")" +rm -f "$RESUME_LOG" + +if [ $resume_exit -eq 0 ]; then + pass "Resume completed successfully" +else + fail "Resume exited $resume_exit (expected 0)" + echo "$resume_output" + exit 1 +fi + +if echo "$resume_output" | grep -q "\[resume\] Skipping preflight (cached)"; then + pass "Resume skipped preflight" +else + fail "Resume did not skip preflight" +fi + +if echo "$resume_output" | grep -q "\[resume\] Skipping gateway (running)"; then + pass "Resume skipped gateway" +else + fail "Resume did not skip gateway" +fi + +if echo "$resume_output" | grep -q "\[resume\] Skipping sandbox (${SANDBOX_NAME})"; then + pass "Resume skipped sandbox" +else + fail "Resume did not skip sandbox" +fi + +if echo "$resume_output" | grep -q "\[1/7\] Preflight checks"; then + fail "Resume reran preflight unexpectedly" +else + pass "Resume did not rerun preflight" +fi + +if echo "$resume_output" | grep -q "\[2/7\] Starting OpenShell gateway"; then + fail "Resume reran gateway startup unexpectedly" +else + pass "Resume did not rerun gateway startup" +fi + +if echo "$resume_output" | grep -q "\[5/7\] Creating sandbox"; then + fail "Resume reran sandbox creation unexpectedly" +else + pass "Resume did not rerun sandbox creation" +fi + +if echo "$resume_output" | grep -q "\[4/7\] Setting up inference provider"; then + pass "Resume continued with inference setup" +else + fail "Resume did not continue with inference setup" +fi + +if run_nemoclaw "$SANDBOX_NAME" status >/dev/null 2>&1; then + pass "Sandbox '$SANDBOX_NAME' is manageable after resume" +else + fail "Sandbox '$SANDBOX_NAME' status failed after resume" +fi + +node -e ' +const fs = require("fs"); +const file = process.argv[1]; +const data = JSON.parse(fs.readFileSync(file, "utf8")); +if (data.status !== "complete") process.exit(1); +if (data.provider !== "nvidia-prod") process.exit(2); +if (data.steps.preflight.status !== "complete") process.exit(3); +if (data.steps.gateway.status !== "complete") process.exit(4); +if (data.steps.sandbox.status !== "complete") process.exit(5); +if (data.steps.provider_selection.status !== "complete") process.exit(6); +if (data.steps.inference.status !== "complete") process.exit(7); +if (data.steps.openclaw.status !== "complete") process.exit(8); +if (data.steps.policies.status !== "complete") process.exit(9); +' "$SESSION_FILE" +case $? in + 0) pass "Session file recorded full completion after resume" ;; + *) fail "Session file did not record the expected completed state after resume" ;; +esac + +if [ -f "$REGISTRY" ] && grep -q "$SANDBOX_NAME" "$REGISTRY"; then + pass "Registry contains resumed sandbox entry" +else + fail "Registry does not contain resumed sandbox entry" +fi + +# ══════════════════════════════════════════════════════════════════ +# Phase 4: Final cleanup +# ══════════════════════════════════════════════════════════════════ +section "Phase 4: Final cleanup" + +run_nemoclaw "$SANDBOX_NAME" destroy 2>/dev/null || true +openshell sandbox delete "$SANDBOX_NAME" 2>/dev/null || true +openshell forward stop 18789 2>/dev/null || true +openshell gateway destroy -g nemoclaw 2>/dev/null || true +rm -f "$SESSION_FILE" + +if openshell sandbox get "$SANDBOX_NAME" >/dev/null 2>&1; then + fail "Sandbox '$SANDBOX_NAME' still exists after cleanup" +else + pass "Sandbox '$SANDBOX_NAME' cleaned up" +fi + +if [ -f "$SESSION_FILE" ]; then + fail "Onboard session file still exists after cleanup" +else + pass "Onboard session file cleaned up" +fi + +pass "Final cleanup complete" + +# ══════════════════════════════════════════════════════════════════ +# Summary +# ══════════════════════════════════════════════════════════════════ +echo "" +echo "========================================" +echo " PASS: $PASS" +echo " FAIL: $FAIL" +echo " SKIP: $SKIP" +echo " TOTAL: $TOTAL" +echo "========================================" +echo "" + +if [ $FAIL -ne 0 ]; then + exit 1 +fi diff --git a/test/e2e/test-spark-install.sh b/test/e2e/test-spark-install.sh new file mode 100755 index 000000000..9d7e2d2a3 --- /dev/null +++ b/test/e2e/test-spark-install.sh @@ -0,0 +1,179 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# DGX Spark install smoke: setup-spark (Docker cgroupns) + install.sh — parity with +# test/integration/spark-install-cli.test.ts and spark-install.md Quick Start. +# +# Prerequisites: +# - Linux (DGX Spark or similar); other OS exits immediately (fail) +# - Docker running +# - sudo (for scripts/setup-spark.sh) unless NEMOCLAW_E2E_SPARK_SKIP_SETUP=1 +# - Same env your non-interactive install needs (e.g. NEMOCLAW_NON_INTERACTIVE=1, NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1, API keys, …) +# +# Environment: +# NEMOCLAW_NON_INTERACTIVE=1 — required (matches full-e2e install phase) +# NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 — required for non-interactive install/onboard +# NEMOCLAW_E2E_SPARK_SKIP_SETUP=1 — skip sudo setup-spark (host already configured) +# NEMOCLAW_E2E_PUBLIC_INSTALL=1 — use curl|bash instead of repo install.sh +# NEMOCLAW_INSTALL_SCRIPT_URL — URL when using public install (default: nemoclaw.sh) +# INSTALL_LOG — log file (default: /tmp/nemoclaw-e2e-spark-install.log) +# +# Usage: +# NEMOCLAW_NON_INTERACTIVE=1 NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 bash test/e2e/test-spark-install.sh +# +# See: spark-install.md + +set -uo pipefail + +PASS=0 +FAIL=0 +TOTAL=0 + +pass() { + ((PASS++)) + ((TOTAL++)) + printf '\033[32m PASS: %s\033[0m\n' "$1" +} +fail() { + ((FAIL++)) + ((TOTAL++)) + printf '\033[31m FAIL: %s\033[0m\n' "$1" +} +section() { + echo "" + printf '\033[1;36m=== %s ===\033[0m\n' "$1" +} +info() { printf '\033[1;34m [info]\033[0m %s\n' "$1"; } + +if [ -d /workspace ] && [ -f /workspace/install.sh ]; then + REPO="/workspace" +elif [ -f "$(cd "$(dirname "$0")/../.." && pwd)/install.sh" ]; then + REPO="$(cd "$(dirname "$0")/../.." && pwd)" +else + echo "ERROR: Cannot find repo root (install.sh)." + exit 1 +fi + +SETUP_SCRIPT="$REPO/scripts/setup-spark.sh" +INSTALL_LOG="${INSTALL_LOG:-/tmp/nemoclaw-e2e-spark-install.log}" + +section "Phase 0: Platform" +if [ "$(uname -s)" = "Linux" ]; then + pass "Running on Linux" +else + fail "This script is for DGX Spark (Linux). On other OS use Vitest: NEMOCLAW_E2E_SPARK_INSTALL=1 --project spark-install-cli (skipped there on non-Linux)." + exit 1 +fi + +section "Phase 1: Prerequisites" +if docker info >/dev/null 2>&1; then + pass "Docker is running" +else + fail "Docker is not running" + exit 1 +fi + +if [ -f "$SETUP_SCRIPT" ]; then + pass "Found scripts/setup-spark.sh" +else + fail "Missing $SETUP_SCRIPT" + exit 1 +fi + +if [ "${NEMOCLAW_NON_INTERACTIVE:-}" = "1" ]; then + pass "NEMOCLAW_NON_INTERACTIVE=1" +else + fail "NEMOCLAW_NON_INTERACTIVE=1 is required" + exit 1 +fi + +if [ "${NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE:-}" = "1" ]; then + pass "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1" +else + fail "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 is required for non-interactive install" + exit 1 +fi + +section "Phase 2: Spark Docker setup (sudo)" +cd "$REPO" || { + fail "cd to repo: $REPO" + exit 1 +} + +if [ "${NEMOCLAW_E2E_SPARK_SKIP_SETUP:-0}" = "1" ]; then + info "Skipping sudo setup-spark (NEMOCLAW_E2E_SPARK_SKIP_SETUP=1)" + pass "setup-spark skipped" +else + info "Running: sudo bash scripts/setup-spark.sh" + if sudo bash "$SETUP_SCRIPT"; then + pass "setup-spark completed" + else + fail "setup-spark failed" + exit 1 + fi +fi + +section "Phase 3: Install NemoClaw (non-interactive)" +info "Log: $INSTALL_LOG" +if [ "${NEMOCLAW_E2E_PUBLIC_INSTALL:-0}" = "1" ]; then + url="${NEMOCLAW_INSTALL_SCRIPT_URL:-https://www.nvidia.com/nemoclaw.sh}" + info "Running: curl -fsSL ... | bash (url=$url)" + curl -fsSL "$url" | NEMOCLAW_NON_INTERACTIVE=1 NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 bash >"$INSTALL_LOG" 2>&1 & +else + info "Running: bash install.sh --non-interactive" + NEMOCLAW_NON_INTERACTIVE=1 NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 bash install.sh --non-interactive >"$INSTALL_LOG" 2>&1 & +fi +install_pid=$! +tail -f "$INSTALL_LOG" --pid=$install_pid 2>/dev/null & +tail_pid=$! +wait "$install_pid" +install_exit=$? +kill "$tail_pid" 2>/dev/null || true +wait "$tail_pid" 2>/dev/null || true + +if [ "$install_exit" -ne 0 ]; then + fail "install failed (exit $install_exit); last 80 lines of log:" + tail -n 80 "$INSTALL_LOG" >&2 || true + exit 1 +fi +pass "install completed (exit 0)" + +if [ -f "$HOME/.bashrc" ]; then + # shellcheck source=/dev/null + source "$HOME/.bashrc" 2>/dev/null || true +fi +export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" +if [ -s "$NVM_DIR/nvm.sh" ]; then + # shellcheck source=/dev/null + . "$NVM_DIR/nvm.sh" +fi +if [ -d "$HOME/.local/bin" ] && [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then + export PATH="$HOME/.local/bin:$PATH" +fi + +section "Phase 4: Verify CLI" +if command -v nemoclaw >/dev/null 2>&1; then + pass "nemoclaw on PATH ($(command -v nemoclaw))" +else + fail "nemoclaw not on PATH" + exit 1 +fi + +if command -v openshell >/dev/null 2>&1; then + pass "openshell on PATH" +else + fail "openshell not on PATH" + exit 1 +fi + +if nemoclaw --help >/dev/null 2>&1; then + pass "nemoclaw --help exits 0" +else + fail "nemoclaw --help failed" + exit 1 +fi + +section "Summary" +printf '\033[1;32mOK: spark-install bash smoke (%d checks passed)\033[0m\n' "$PASS" +echo " Log: $INSTALL_LOG" diff --git a/test/e2e/test-telegram-injection.sh b/test/e2e/test-telegram-injection.sh new file mode 100755 index 000000000..47e8a98a2 --- /dev/null +++ b/test/e2e/test-telegram-injection.sh @@ -0,0 +1,471 @@ +#!/bin/bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# shellcheck disable=SC2016,SC2034,SC2329 +# SC2016: Single-quoted strings are intentional — these are injection payloads +# that must NOT be expanded by the shell. +# SC2034: Some variables are used indirectly or reserved for future test cases. +# SC2329: Helper functions may be invoked conditionally or in later test phases. + +# Telegram Bridge Command Injection E2E Tests +# +# Validates that PR #119's fix prevents shell command injection through +# the Telegram bridge. Tests the runAgentInSandbox() code path by +# invoking the bridge's message-handling logic directly against a real +# sandbox, without requiring a live Telegram bot token. +# +# Attack surface: +# Before the fix, user messages were interpolated into a shell command +# string passed over SSH. $(cmd), `cmd`, and ${VAR} expansions inside +# user messages would execute in the sandbox, allowing credential +# exfiltration and arbitrary code execution. +# +# Prerequisites: +# - Docker running +# - NemoClaw installed and sandbox running (test-full-e2e.sh Phase 0-3) +# - NVIDIA_API_KEY set +# - openshell on PATH +# +# Environment variables: +# NEMOCLAW_SANDBOX_NAME — sandbox name (default: e2e-test) +# NVIDIA_API_KEY — required +# +# Usage: +# NEMOCLAW_NON_INTERACTIVE=1 NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 NVIDIA_API_KEY=nvapi-... bash test/e2e/test-telegram-injection.sh +# +# See: https://github.com/NVIDIA/NemoClaw/issues/118 +# https://github.com/NVIDIA/NemoClaw/pull/119 + +set -uo pipefail + +PASS=0 +FAIL=0 +SKIP=0 +TOTAL=0 + +pass() { + ((PASS++)) + ((TOTAL++)) + printf '\033[32m PASS: %s\033[0m\n' "$1" +} +fail() { + ((FAIL++)) + ((TOTAL++)) + printf '\033[31m FAIL: %s\033[0m\n' "$1" +} +skip() { + ((SKIP++)) + ((TOTAL++)) + printf '\033[33m SKIP: %s\033[0m\n' "$1" +} +section() { + echo "" + printf '\033[1;36m=== %s ===\033[0m\n' "$1" +} +info() { printf '\033[1;34m [info]\033[0m %s\n' "$1"; } + +# Determine repo root +if [ -d /workspace ] && [ -f /workspace/install.sh ]; then + REPO="/workspace" +elif [ -f "$(cd "$(dirname "$0")/../.." && pwd)/install.sh" ]; then + REPO="$(cd "$(dirname "$0")/../.." && pwd)" +else + echo "ERROR: Cannot find repo root." + exit 1 +fi + +SANDBOX_NAME="${NEMOCLAW_SANDBOX_NAME:-e2e-test}" + +# ══════════════════════════════════════════════════════════════════ +# Helper: send a message to the agent inside the sandbox using the +# same mechanism as the Telegram bridge (SSH + nemoclaw-start). +# +# This exercises the exact code path that was vulnerable: user message +# → shell command → SSH → sandbox execution. +# +# We use the bridge's actual shellQuote + execFileSync approach from +# the fixed code on main. The test validates that the message content +# is treated as literal data, not shell commands. +# ══════════════════════════════════════════════════════════════════ + +send_message_to_sandbox() { + local message="$1" + local session_id="${2:-e2e-injection-test}" + + local ssh_config + ssh_config="$(mktemp)" + openshell sandbox ssh-config "$SANDBOX_NAME" >"$ssh_config" 2>/dev/null + + # Use the same mechanism as the bridge: pass message as an argument + # via SSH. The key security property is that the message must NOT be + # interpreted as shell code on the remote side. + local result + result=$(timeout 90 ssh -F "$ssh_config" \ + -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null \ + -o ConnectTimeout=10 \ + -o LogLevel=ERROR \ + "openshell-${SANDBOX_NAME}" \ + "echo 'INJECTION_PROBE_START' && echo $(printf '%q' "$message") && echo 'INJECTION_PROBE_END'" \ + 2>&1) || true + + rm -f "$ssh_config" + echo "$result" +} + +# Run a command inside the sandbox and capture output +sandbox_exec() { + local cmd="$1" + local ssh_config + ssh_config="$(mktemp)" + openshell sandbox ssh-config "$SANDBOX_NAME" >"$ssh_config" 2>/dev/null + + local result + result=$(timeout 60 ssh -F "$ssh_config" \ + -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null \ + -o ConnectTimeout=10 \ + -o LogLevel=ERROR \ + "openshell-${SANDBOX_NAME}" \ + "$cmd" \ + 2>&1) || true + + rm -f "$ssh_config" + echo "$result" +} + +# ══════════════════════════════════════════════════════════════════ +# Phase 0: Prerequisites +# ══════════════════════════════════════════════════════════════════ +section "Phase 0: Prerequisites" + +if [ -z "${NVIDIA_API_KEY:-}" ]; then + fail "NVIDIA_API_KEY not set" + exit 1 +fi +pass "NVIDIA_API_KEY is set" + +if ! command -v openshell >/dev/null 2>&1; then + fail "openshell not found on PATH" + exit 1 +fi +pass "openshell found" + +if ! command -v nemoclaw >/dev/null 2>&1; then + fail "nemoclaw not found on PATH" + exit 1 +fi +pass "nemoclaw found" + +# Verify sandbox is running +if status_output=$(nemoclaw "$SANDBOX_NAME" status 2>&1); then + pass "Sandbox '${SANDBOX_NAME}' is running" +else + fail "Sandbox '${SANDBOX_NAME}' not running — run test-full-e2e.sh first" + exit 1 +fi + +# Get the actual hostname inside the sandbox for comparison +SANDBOX_HOSTNAME=$(sandbox_exec "hostname" 2>/dev/null) || SANDBOX_HOSTNAME="" +SANDBOX_WHOAMI=$(sandbox_exec "whoami" 2>/dev/null) || SANDBOX_WHOAMI="" +info "Sandbox hostname: ${SANDBOX_HOSTNAME:-unknown}, user: ${SANDBOX_WHOAMI:-unknown}" + +# ══════════════════════════════════════════════════════════════════ +# Phase 1: Command Substitution Injection — $(command) +# ══════════════════════════════════════════════════════════════════ +section "Phase 1: Command Substitution Injection" + +# T1: $(whoami) must be treated as literal text, not executed +info "T1: Testing \$(whoami) injection..." +MARKER_FILE="/tmp/nemoclaw-injection-t1-$(date +%s)" +PAYLOAD='$(touch /tmp/injection-proof-t1 && echo INJECTED)' + +# Write the payload as a file in sandbox, then check if /tmp/injection-proof-t1 was created +sandbox_exec "rm -f /tmp/injection-proof-t1" >/dev/null 2>&1 + +# Use printf %q to safely pass the payload through SSH without local expansion +# This simulates what shellQuote does in the bridge +ssh_config_t1="$(mktemp)" +openshell sandbox ssh-config "$SANDBOX_NAME" >"$ssh_config_t1" 2>/dev/null + +# The critical test: pass a payload that would create a file if command +# substitution is executed. Use stdin to pass the message (like the fixed bridge). +timeout 30 ssh -F "$ssh_config_t1" \ + -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null \ + -o LogLevel=ERROR \ + "openshell-${SANDBOX_NAME}" \ + 'MSG=$(cat) && echo "Received: $MSG"' \ + <<<"$PAYLOAD" >/dev/null 2>&1 || true +rm -f "$ssh_config_t1" + +# Check if the injection file was created +injection_check=$(sandbox_exec "test -f /tmp/injection-proof-t1 && echo EXPLOITED || echo SAFE") +if echo "$injection_check" | grep -q "SAFE"; then + pass "T1: \$(command) substitution was NOT executed" +else + fail "T1: \$(command) substitution was EXECUTED — injection successful!" +fi + +# T2: Backtick injection — `command` +info "T2: Testing backtick injection..." +sandbox_exec "rm -f /tmp/injection-proof-t2" >/dev/null 2>&1 + +ssh_config_t2="$(mktemp)" +openshell sandbox ssh-config "$SANDBOX_NAME" >"$ssh_config_t2" 2>/dev/null +PAYLOAD_BT='`touch /tmp/injection-proof-t2`' + +timeout 30 ssh -F "$ssh_config_t2" \ + -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null \ + -o LogLevel=ERROR \ + "openshell-${SANDBOX_NAME}" \ + 'MSG=$(cat) && echo "Received: $MSG"' \ + <<<"$PAYLOAD_BT" >/dev/null 2>&1 || true +rm -f "$ssh_config_t2" + +injection_check_t2=$(sandbox_exec "test -f /tmp/injection-proof-t2 && echo EXPLOITED || echo SAFE") +if echo "$injection_check_t2" | grep -q "SAFE"; then + pass "T2: Backtick command substitution was NOT executed" +else + fail "T2: Backtick command substitution was EXECUTED — injection successful!" +fi + +# ══════════════════════════════════════════════════════════════════ +# Phase 2: Quote Breakout Injection +# ══════════════════════════════════════════════════════════════════ +section "Phase 2: Quote Breakout Injection" + +# T3: Classic single-quote breakout +info "T3: Testing single-quote breakout..." +sandbox_exec "rm -f /tmp/injection-proof-t3" >/dev/null 2>&1 + +ssh_config_t3="$(mktemp)" +openshell sandbox ssh-config "$SANDBOX_NAME" >"$ssh_config_t3" 2>/dev/null +PAYLOAD_QUOTE="'; touch /tmp/injection-proof-t3; echo '" + +timeout 30 ssh -F "$ssh_config_t3" \ + -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null \ + -o LogLevel=ERROR \ + "openshell-${SANDBOX_NAME}" \ + 'MSG=$(cat) && echo "Received: $MSG"' \ + <<<"$PAYLOAD_QUOTE" >/dev/null 2>&1 || true +rm -f "$ssh_config_t3" + +injection_check_t3=$(sandbox_exec "test -f /tmp/injection-proof-t3 && echo EXPLOITED || echo SAFE") +if echo "$injection_check_t3" | grep -q "SAFE"; then + pass "T3: Single-quote breakout was NOT exploitable" +else + fail "T3: Single-quote breakout was EXECUTED — injection successful!" +fi + +# ══════════════════════════════════════════════════════════════════ +# Phase 3: Environment Variable / Parameter Expansion +# ══════════════════════════════════════════════════════════════════ +section "Phase 3: Parameter Expansion" + +# T4: ${NVIDIA_API_KEY} must not expand to the actual key value +info "T4: Testing \${NVIDIA_API_KEY} expansion..." + +ssh_config_t4="$(mktemp)" +openshell sandbox ssh-config "$SANDBOX_NAME" >"$ssh_config_t4" 2>/dev/null +PAYLOAD_ENV='${NVIDIA_API_KEY}' + +t4_result=$(timeout 30 ssh -F "$ssh_config_t4" \ + -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null \ + -o LogLevel=ERROR \ + "openshell-${SANDBOX_NAME}" \ + 'MSG=$(cat) && echo "$MSG"' \ + <<<"$PAYLOAD_ENV" 2>&1) || true +rm -f "$ssh_config_t4" + +# The result should contain the literal string ${NVIDIA_API_KEY}, not a nvapi- value +if echo "$t4_result" | grep -q "nvapi-"; then + fail "T4: \${NVIDIA_API_KEY} expanded to actual key value — secret leaked!" +elif echo "$t4_result" | grep -qF '${NVIDIA_API_KEY}'; then + pass "T4: \${NVIDIA_API_KEY} treated as literal string (not expanded)" +else + # Empty or other result — still safe as long as key not leaked + pass "T4: \${NVIDIA_API_KEY} did not expand to key value (result: ${t4_result:0:100})" +fi + +# ══════════════════════════════════════════════════════════════════ +# Phase 4: API Key Not in Process Table +# ══════════════════════════════════════════════════════════════════ +section "Phase 4: Process Table Leak Check" + +# T5: NVIDIA_API_KEY must not appear in ps aux output +info "T5: Checking process table for API key leaks..." + +# Get truncated key for a safe comparison (first 15 chars of key value) +API_KEY_PREFIX="${NVIDIA_API_KEY:0:15}" + +# Check both the Brev host and inside the sandbox +host_ps=$(ps aux 2>/dev/null || true) +sandbox_ps=$(sandbox_exec "ps aux" 2>/dev/null || true) + +HOST_LEAK=false +SANDBOX_LEAK=false + +if echo "$host_ps" | grep -qF "$API_KEY_PREFIX"; then + # Filter out our own grep and this test script + leaky_lines=$(echo "$host_ps" | grep -F "$API_KEY_PREFIX" | grep -v "grep" | grep -v "test-telegram-injection" || true) + if [ -n "$leaky_lines" ]; then + HOST_LEAK=true + fi +fi + +if echo "$sandbox_ps" | grep -qF "$API_KEY_PREFIX"; then + leaky_sandbox=$(echo "$sandbox_ps" | grep -F "$API_KEY_PREFIX" | grep -v "grep" || true) + if [ -n "$leaky_sandbox" ]; then + SANDBOX_LEAK=true + fi +fi + +if [ "$HOST_LEAK" = true ]; then + fail "T5: NVIDIA_API_KEY found in HOST process table" +elif [ "$SANDBOX_LEAK" = true ]; then + fail "T5: NVIDIA_API_KEY found in SANDBOX process table" +else + pass "T5: API key not visible in process tables (host or sandbox)" +fi + +# ══════════════════════════════════════════════════════════════════ +# Phase 5: SANDBOX_NAME Validation +# ══════════════════════════════════════════════════════════════════ +section "Phase 5: SANDBOX_NAME Validation" + +# T6: Invalid SANDBOX_NAME with shell metacharacters must be rejected +info "T6: Testing SANDBOX_NAME with shell metacharacters..." + +# The validateName() function in runner.js enforces RFC 1123: lowercase +# alphanumeric with optional internal hyphens, max 63 chars. +# Test by running the validation directly via node. +t6_result=$(cd "$REPO" && node -e " + const { validateName } = require('./bin/lib/runner'); + try { + validateName('foo;rm -rf /', 'SANDBOX_NAME'); + console.log('ACCEPTED'); + } catch (e) { + console.log('REJECTED: ' + e.message); + } +" 2>&1) + +if echo "$t6_result" | grep -q "REJECTED"; then + pass "T6: SANDBOX_NAME 'foo;rm -rf /' rejected by validateName()" +else + fail "T6: SANDBOX_NAME 'foo;rm -rf /' was ACCEPTED — validation bypass!" +fi + +# T7: Leading-hyphen option injection must be rejected +info "T7: Testing SANDBOX_NAME with leading hyphen (option injection)..." + +t7_result=$(cd "$REPO" && node -e " + const { validateName } = require('./bin/lib/runner'); + try { + validateName('--help', 'SANDBOX_NAME'); + console.log('ACCEPTED'); + } catch (e) { + console.log('REJECTED: ' + e.message); + } +" 2>&1) + +if echo "$t7_result" | grep -q "REJECTED"; then + pass "T7: SANDBOX_NAME '--help' rejected (option injection prevented)" +else + fail "T7: SANDBOX_NAME '--help' was ACCEPTED — option injection possible!" +fi + +# Additional invalid names — pass via process.argv to avoid shell expansion of +# backticks and $() in double-quoted node -e strings. +for invalid_name in '$(whoami)' '`id`' 'foo bar' '../etc/passwd' 'UPPERCASE'; do + t_result=$(cd "$REPO" && node -e " + const { validateName } = require('./bin/lib/runner'); + try { + validateName(process.argv[1], 'SANDBOX_NAME'); + console.log('ACCEPTED'); + } catch (e) { + console.log('REJECTED'); + } + " -- "$invalid_name" 2>&1) + + if echo "$t_result" | grep -q "REJECTED"; then + pass "T6/T7 extra: SANDBOX_NAME '${invalid_name}' correctly rejected" + else + fail "T6/T7 extra: SANDBOX_NAME '${invalid_name}' was ACCEPTED" + fi +done + +# ══════════════════════════════════════════════════════════════════ +# Phase 6: Regression — Normal Messages Still Work +# ══════════════════════════════════════════════════════════════════ +section "Phase 6: Normal Message Regression" + +# T8: A normal message should be passed through correctly +info "T8: Testing normal message passthrough..." + +ssh_config_t8="$(mktemp)" +openshell sandbox ssh-config "$SANDBOX_NAME" >"$ssh_config_t8" 2>/dev/null +NORMAL_MSG="Hello, what is two plus two?" + +t8_result=$(timeout 30 ssh -F "$ssh_config_t8" \ + -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null \ + -o LogLevel=ERROR \ + "openshell-${SANDBOX_NAME}" \ + 'MSG=$(cat) && echo "Received: $MSG"' \ + <<<"$NORMAL_MSG" 2>&1) || true +rm -f "$ssh_config_t8" + +if echo "$t8_result" | grep -qF "Hello, what is two plus two?"; then + pass "T8: Normal message passed through correctly" +else + fail "T8: Normal message was not echoed back correctly (got: ${t8_result:0:200})" +fi + +# T8b: Test message with special characters that should be treated as literal +info "T8b: Testing message with safe special characters..." + +ssh_config_t8b="$(mktemp)" +openshell sandbox ssh-config "$SANDBOX_NAME" >"$ssh_config_t8b" 2>/dev/null +SPECIAL_MSG="What's the meaning of life? It costs \$5 & is 100% free!" + +t8b_result=$(timeout 30 ssh -F "$ssh_config_t8b" \ + -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null \ + -o LogLevel=ERROR \ + "openshell-${SANDBOX_NAME}" \ + 'MSG=$(cat) && echo "$MSG"' \ + <<<"$SPECIAL_MSG" 2>&1) || true +rm -f "$ssh_config_t8b" + +# Check the message was received (may be slightly different due to shell, but +# the key test is that $ and & didn't cause errors or unexpected behavior) +if [ -n "$t8b_result" ]; then + pass "T8b: Message with special characters processed without error" +else + fail "T8b: Message with special characters caused empty/error response" +fi + +# ══════════════════════════════════════════════════════════════════ +# Summary +# ══════════════════════════════════════════════════════════════════ +echo "" +echo "========================================" +echo " Telegram Injection Test Results:" +echo " Passed: $PASS" +echo " Failed: $FAIL" +echo " Skipped: $SKIP" +echo " Total: $TOTAL" +echo "========================================" + +if [ "$FAIL" -eq 0 ]; then + printf '\n\033[1;32m Telegram injection tests PASSED — no injection vectors found.\033[0m\n' + exit 0 +else + printf '\n\033[1;31m %d test(s) failed — INJECTION VULNERABILITIES DETECTED.\033[0m\n' "$FAIL" + exit 1 +fi diff --git a/test/gateway-cleanup.test.js b/test/gateway-cleanup.test.js new file mode 100644 index 000000000..2b6ff04d5 --- /dev/null +++ b/test/gateway-cleanup.test.js @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Verify that gateway cleanup includes Docker volume removal in all +// failure paths. Without this, failed gateway starts leave corrupted +// volumes (openshell-cluster-*) that break subsequent onboard runs. +// +// See: https://github.com/NVIDIA/NemoClaw/issues/17 + +import { describe, it, expect } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; + +const ROOT = path.resolve(import.meta.dirname, ".."); + +describe("gateway cleanup: Docker volumes removed on failure (#17)", () => { + it("onboard.js: destroyGateway() removes Docker volumes", () => { + const content = fs.readFileSync(path.join(ROOT, "bin/lib/onboard.js"), "utf-8"); + expect(content.includes("docker volume") && content.includes("openshell-cluster")).toBe(true); + }); + + it("onboard.js: volume cleanup runs on gateway start failure", () => { + const content = fs.readFileSync(path.join(ROOT, "bin/lib/onboard.js"), "utf-8"); + const startGwBlock = content.match(/async function startGatewayWithOptions[\s\S]*?^}/m); + expect(startGwBlock).toBeTruthy(); + + // Current behavior: + // 1. stale gateway is detected but NOT destroyed upfront — gateway start + // can recover the container without wiping metadata/certs + // 2. destroyGateway() runs inside the retry loop only on genuine failure + expect(startGwBlock[0].includes("if (hasStaleGateway(gwInfo))")).toBe(true); + expect(startGwBlock[0]).toContain("destroyGateway()"); + }); + + it("uninstall.sh: includes Docker volume cleanup", () => { + const content = fs.readFileSync(path.join(ROOT, "uninstall.sh"), "utf-8"); + expect(content.includes("docker volume") && content.includes("openshell-cluster")).toBe(true); + expect(content.includes("remove_related_docker_volumes")).toBe(true); + }); +}); diff --git a/test/inference-config.test.js b/test/inference-config.test.js deleted file mode 100644 index 9d16dbadc..000000000 --- a/test/inference-config.test.js +++ /dev/null @@ -1,148 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import assert from "node:assert/strict"; -import { describe, it, expect } from "vitest"; - -import { - CLOUD_MODEL_OPTIONS, - DEFAULT_OLLAMA_MODEL, - DEFAULT_ROUTE_CREDENTIAL_ENV, - DEFAULT_ROUTE_PROFILE, - INFERENCE_ROUTE_URL, - MANAGED_PROVIDER_ID, - getOpenClawPrimaryModel, - getProviderSelectionConfig, -} from "../bin/lib/inference-config"; - -describe("inference selection config", () => { - it("exposes the curated cloud model picker options", () => { - expect(CLOUD_MODEL_OPTIONS.map((option) => option.id)).toEqual([ - "nvidia/nemotron-3-super-120b-a12b", - "moonshotai/kimi-k2.5", - "z-ai/glm5", - "minimaxai/minimax-m2.5", - "qwen/qwen3.5-397b-a17b", - "openai/gpt-oss-120b", - ]); - }); - - it("maps ollama-local to the sandbox inference route and default model", () => { - expect(getProviderSelectionConfig("ollama-local")).toEqual({ - endpointType: "custom", - endpointUrl: INFERENCE_ROUTE_URL, - ncpPartner: null, - model: DEFAULT_OLLAMA_MODEL, - profile: DEFAULT_ROUTE_PROFILE, - credentialEnv: DEFAULT_ROUTE_CREDENTIAL_ENV, - provider: "ollama-local", - providerLabel: "Local Ollama", - }); - }); - - it("maps nvidia-nim to the sandbox inference route", () => { - expect( - getProviderSelectionConfig("nvidia-nim", "nvidia/nemotron-3-super-120b-a12b") - ).toEqual({ - endpointType: "custom", - endpointUrl: INFERENCE_ROUTE_URL, - ncpPartner: null, - model: "nvidia/nemotron-3-super-120b-a12b", - profile: DEFAULT_ROUTE_PROFILE, - credentialEnv: DEFAULT_ROUTE_CREDENTIAL_ENV, - provider: "nvidia-nim", - providerLabel: "NVIDIA Endpoints", - }); - }); - - it("maps compatible-anthropic-endpoint to the sandbox inference route", () => { - assert.deepEqual(getProviderSelectionConfig("compatible-anthropic-endpoint", "claude-sonnet-proxy"), { - endpointType: "custom", - endpointUrl: INFERENCE_ROUTE_URL, - ncpPartner: null, - model: "claude-sonnet-proxy", - profile: DEFAULT_ROUTE_PROFILE, - credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY", - provider: "compatible-anthropic-endpoint", - providerLabel: "Other Anthropic-compatible endpoint", - }); - }); - - it("maps the remaining hosted providers to the sandbox inference route", () => { - expect(getProviderSelectionConfig("openai-api", "gpt-5.4-mini")).toEqual({ - endpointType: "custom", - endpointUrl: INFERENCE_ROUTE_URL, - ncpPartner: null, - model: "gpt-5.4-mini", - profile: DEFAULT_ROUTE_PROFILE, - credentialEnv: "OPENAI_API_KEY", - provider: "openai-api", - providerLabel: "OpenAI", - }); - - expect(getProviderSelectionConfig("anthropic-prod", "claude-sonnet-4-6")).toEqual({ - endpointType: "custom", - endpointUrl: INFERENCE_ROUTE_URL, - ncpPartner: null, - model: "claude-sonnet-4-6", - profile: DEFAULT_ROUTE_PROFILE, - credentialEnv: "ANTHROPIC_API_KEY", - provider: "anthropic-prod", - providerLabel: "Anthropic", - }); - - expect(getProviderSelectionConfig("gemini-api", "gemini-2.5-pro")).toEqual({ - endpointType: "custom", - endpointUrl: INFERENCE_ROUTE_URL, - ncpPartner: null, - model: "gemini-2.5-pro", - profile: DEFAULT_ROUTE_PROFILE, - credentialEnv: "GEMINI_API_KEY", - provider: "gemini-api", - providerLabel: "Google Gemini", - }); - - expect(getProviderSelectionConfig("compatible-endpoint", "openrouter/auto")).toEqual({ - endpointType: "custom", - endpointUrl: INFERENCE_ROUTE_URL, - ncpPartner: null, - model: "openrouter/auto", - profile: DEFAULT_ROUTE_PROFILE, - credentialEnv: "COMPATIBLE_API_KEY", - provider: "compatible-endpoint", - providerLabel: "Other OpenAI-compatible endpoint", - }); - - expect(getProviderSelectionConfig("vllm-local", "meta-llama")).toEqual({ - endpointType: "custom", - endpointUrl: INFERENCE_ROUTE_URL, - ncpPartner: null, - model: "meta-llama", - profile: DEFAULT_ROUTE_PROFILE, - credentialEnv: DEFAULT_ROUTE_CREDENTIAL_ENV, - provider: "vllm-local", - providerLabel: "Local vLLM", - }); - }); - - it("returns null for unknown providers", () => { - expect(getProviderSelectionConfig("bogus-provider")).toBe(null); - }); - - it("builds a qualified OpenClaw primary model for ollama-local", () => { - expect(getOpenClawPrimaryModel("ollama-local", "nemotron-3-nano:30b")).toBe(`${MANAGED_PROVIDER_ID}/nemotron-3-nano:30b`); - }); - - it("falls back to provider defaults when model is omitted", () => { - expect(getProviderSelectionConfig("openai-api").model).toBe("gpt-5.4"); - expect(getProviderSelectionConfig("anthropic-prod").model).toBe("claude-sonnet-4-6"); - expect(getProviderSelectionConfig("gemini-api").model).toBe("gemini-2.5-flash"); - expect(getProviderSelectionConfig("compatible-endpoint").model).toBe("custom-model"); - expect(getProviderSelectionConfig("compatible-anthropic-endpoint").model).toBe("custom-anthropic-model"); - expect(getProviderSelectionConfig("vllm-local").model).toBe("vllm-local"); - }); - - it("builds a default OpenClaw primary model for non-ollama providers", () => { - expect(getOpenClawPrimaryModel("nvidia-prod")).toBe(`${MANAGED_PROVIDER_ID}/nvidia/nemotron-3-super-120b-a12b`); - }); -}); diff --git a/test/install-preflight.test.js b/test/install-preflight.test.js index 1a8bd5f4c..9be405954 100644 --- a/test/install-preflight.test.js +++ b/test/install-preflight.test.js @@ -8,7 +8,8 @@ import path from "node:path"; import { spawnSync } from "node:child_process"; const INSTALLER = path.join(import.meta.dirname, "..", "install.sh"); -const CURL_PIPE_INSTALLER = path.join(import.meta.dirname, "..", "scripts", "install.sh"); +const CURL_PIPE_INSTALLER = path.join(import.meta.dirname, "..", "install.sh"); +const INSTALLER_PAYLOAD = path.join(import.meta.dirname, "..", "scripts", "install.sh"); const GITHUB_INSTALL_URL = "git+https://github.com/NVIDIA/NemoClaw.git"; const TEST_SYSTEM_PATH = "/usr/bin:/bin"; @@ -20,18 +21,17 @@ function writeExecutable(target, contents) { // Helpers shared across suites // --------------------------------------------------------------------------- -/** Fake node that reports v22.14.0. */ +/** Fake node that reports v22.16.0. */ function writeNodeStub(fakeBin) { writeExecutable( path.join(fakeBin, "node"), `#!/usr/bin/env bash -if [ "$1" = "--version" ] || [ "$1" = "-v" ]; then echo "v22.14.0"; exit 0; fi +if [ "$1" = "--version" ] || [ "$1" = "-v" ]; then echo "v22.16.0"; exit 0; fi +if [ -n "\${1:-}" ] && [ -f "$1" ]; then + exec ${JSON.stringify(process.execPath)} "$@" +fi if [ "$1" = "-e" ]; then - if [[ "$2" == *"dependencies.openclaw"* ]]; then - echo "2026.3.11" - exit 0 - fi - exit 0 + exec ${JSON.stringify(process.execPath)} "$@" fi exit 99`, ); @@ -58,7 +58,7 @@ echo "unexpected npm invocation: $*" >&2; exit 98`, // --------------------------------------------------------------------------- describe("installer runtime preflight", () => { - it("fails fast with a clear message on unsupported Node.js and npm", () => { + it("attempts nvm upgrade when system Node.js is below minimum version", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-preflight-")); const fakeBin = path.join(tmp, "bin"); fs.mkdirSync(fakeBin); @@ -87,6 +87,14 @@ exit 98 `, ); + // Fake curl that fails — prevents real nvm download and keeps the test fast. + writeExecutable( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +exit 1 +`, + ); + const result = spawnSync("bash", [INSTALLER], { cwd: path.join(import.meta.dirname, ".."), encoding: "utf-8", @@ -99,13 +107,12 @@ exit 98 const output = `${result.stdout}${result.stderr}`; expect(result.status).not.toBe(0); - expect(output).toMatch(/Unsupported runtime detected/); - expect(output).toMatch(/Node\.js >=20 and npm >=10/); - expect(output).toMatch(/v18\.19\.1/); - expect(output).toMatch(/9\.8\.1/); + expect(output).toMatch(/v18\.19\.1.*found but NemoClaw requires/); + expect(output).toMatch(/upgrading via nvm/); + expect(output).toMatch(/Failed to download nvm installer/); }); - it("uses the HTTPS GitHub fallback when not installing from a repo checkout", () => { + it("treats the installer script's checkout as the source root even when cwd is elsewhere", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-fallback-")); const fakeBin = path.join(tmp, "bin"); const prefix = path.join(tmp, "prefix"); @@ -117,9 +124,12 @@ exit 98 path.join(fakeBin, "node"), `#!/usr/bin/env bash if [ "$1" = "--version" ]; then - echo "v22.14.0" + echo "v22.16.0" exit 0 fi +if [ -n "\${1:-}" ] && [ -f "$1" ]; then + exec ${JSON.stringify(process.execPath)} "$@" +fi if [ "$1" = "-e" ]; then exit 1 fi @@ -132,6 +142,9 @@ exit 99 path.join(fakeBin, "git"), `#!/usr/bin/env bash printf '%s\\n' "$*" >> "$GIT_LOG_PATH" +if [ "\${1:-}" = "-c" ]; then + shift 2 +fi if [ "$1" = "clone" ]; then target="\${@: -1}" mkdir -p "$target/nemoclaw" @@ -192,14 +205,17 @@ exit 98 HOME: tmp, PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", NPM_PREFIX: prefix, GIT_LOG_PATH: gitLog, }, }); expect(result.status).toBe(0); - expect(fs.readFileSync(gitLog, "utf-8")).toMatch(/clone.*NemoClaw\.git/); - }); + const gitCalls = fs.readFileSync(gitLog, "utf-8"); + expect(gitCalls).not.toMatch(/clone/); + expect(gitCalls).not.toMatch(/fetch/); + }, 60_000); it("prints the HTTPS GitHub remediation when the binary is missing", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-remediation-")); @@ -212,7 +228,7 @@ exit 98 path.join(fakeBin, "node"), `#!/usr/bin/env bash if [ "$1" = "--version" ]; then - echo "v22.14.0" + echo "v22.16.0" exit 0 fi if [ "$1" = "-e" ]; then @@ -226,6 +242,9 @@ exit 99 writeExecutable( path.join(fakeBin, "git"), `#!/usr/bin/env bash +if [ "\${1:-}" = "-c" ]; then + shift 2 +fi if [ "$1" = "clone" ]; then target="\${@: -1}" mkdir -p "$target/nemoclaw" @@ -274,6 +293,7 @@ exit 98 HOME: tmp, PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", NPM_PREFIX: prefix, }, }); @@ -284,198 +304,36 @@ exit 98 expect(output).not.toMatch(/npm install -g nemoclaw/); }); - it("does not silently prefer Colima when both macOS runtimes are available", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-macos-runtime-choice-")); - const fakeBin = path.join(tmp, "bin"); - const colimaSocket = path.join(tmp, ".colima/default/docker.sock"); - const dockerDesktopSocket = path.join(tmp, ".docker/run/docker.sock"); - fs.mkdirSync(fakeBin); - - writeExecutable( - path.join(fakeBin, "node"), - `#!/usr/bin/env bash -if [ "$1" = "-v" ] || [ "$1" = "--version" ]; then - echo "v22.14.0" - exit 0 -fi -exit 99 -`, - ); - - writeExecutable( - path.join(fakeBin, "npm"), - `#!/usr/bin/env bash -if [ "$1" = "--version" ]; then - echo "10.9.2" - exit 0 -fi -echo "/tmp/npm-prefix" -exit 0 -`, - ); - - writeExecutable( - path.join(fakeBin, "docker"), - `#!/usr/bin/env bash -if [ "$1" = "info" ]; then - exit 1 -fi -exit 0 -`, - ); - - writeExecutable( - path.join(fakeBin, "colima"), - `#!/usr/bin/env bash -echo "colima should not be started" >&2 -exit 97 -`, - ); - - writeExecutable( - path.join(fakeBin, "uname"), - `#!/usr/bin/env bash -if [ "$1" = "-s" ]; then - echo "Darwin" - exit 0 -fi -if [ "$1" = "-m" ]; then - echo "arm64" - exit 0 -fi -echo "Darwin" -`, - ); - - const result = spawnSync("bash", [CURL_PIPE_INSTALLER], { + it("scripts/install.sh runs as the installer from a repo checkout", () => { + const result = spawnSync("bash", [INSTALLER_PAYLOAD, "--help"], { cwd: path.join(import.meta.dirname, ".."), encoding: "utf-8", - env: { - ...process.env, - HOME: tmp, - PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, - NEMOCLAW_TEST_SOCKET_PATHS: `${colimaSocket}:${dockerDesktopSocket}`, - }, }); const output = `${result.stdout}${result.stderr}`; - expect(result.status).not.toBe(0); - expect(output).toMatch(/Both Colima and Docker Desktop are available/); - expect(output).not.toMatch(/colima should not be started/); + expect(result.status).toBe(0); + expect(output).toMatch(/NemoClaw Installer/); + expect(output).not.toMatch(/deprecated compatibility wrapper/); }); - it("can run via stdin without a sibling runtime.sh file", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-curl-pipe-installer-")); - const fakeBin = path.join(tmp, "bin"); - const prefix = path.join(tmp, "prefix"); - fs.mkdirSync(fakeBin); - fs.mkdirSync(path.join(prefix, "bin"), { recursive: true }); - - writeExecutable( - path.join(fakeBin, "node"), - `#!/usr/bin/env bash -if [ "$1" = "-v" ] || [ "$1" = "--version" ]; then - echo "v22.14.0" - exit 0 -fi -if [ "$1" = "-e" ]; then - exit 1 -fi -exit 99 -`, - ); - - writeExecutable( - path.join(fakeBin, "git"), - `#!/usr/bin/env bash -if [ "$1" = "clone" ]; then - target="\${@: -1}" - mkdir -p "$target/nemoclaw" - echo '{"name":"nemoclaw","version":"0.1.0","dependencies":{"openclaw":"2026.3.11"}}' > "$target/package.json" - echo '{"name":"nemoclaw-plugin","version":"0.1.0"}' > "$target/nemoclaw/package.json" - exit 0 -fi -exit 0 -`, - ); - - writeExecutable( - path.join(fakeBin, "npm"), - `#!/usr/bin/env bash -set -euo pipefail -if [ "$1" = "--version" ]; then - echo "10.9.2" - exit 0 -fi -if [ "$1" = "config" ] && [ "$2" = "get" ] && [ "$3" = "prefix" ]; then - echo "$NPM_PREFIX" - exit 0 -fi -if [ "$1" = "pack" ]; then - exit 1 -fi -if [ "$1" = "install" ] && [[ "$*" == *"--ignore-scripts"* ]]; then - exit 0 -fi -if [ "$1" = "run" ]; then - exit 0 -fi -if [ "$1" = "link" ]; then - cat > "$NPM_PREFIX/bin/nemoclaw" <<'EOS' -#!/usr/bin/env bash -if [ "$1" = "--version" ]; then - echo "v0.1.0-test" - exit 0 -fi -exit 0 -EOS - chmod +x "$NPM_PREFIX/bin/nemoclaw" - exit 0 -fi -echo "unexpected npm invocation: $*" >&2 -exit 98 -`, - ); - - writeExecutable( - path.join(fakeBin, "docker"), - `#!/usr/bin/env bash -if [ "$1" = "info" ]; then - exit 0 -fi -exit 0 -`, - ); - - writeExecutable( - path.join(fakeBin, "openshell"), - `#!/usr/bin/env bash -if [ "$1" = "--version" ]; then - echo "openshell 0.0.9" - exit 0 -fi -exit 0 -`, - ); - - const scriptContents = fs.readFileSync(CURL_PIPE_INSTALLER, "utf-8"); - const result = spawnSync("bash", [], { + it("scripts/install.sh --help works when run directly outside a repo checkout", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-installer-payload-stdin-")); + const scriptContents = fs.readFileSync(INSTALLER_PAYLOAD, "utf-8"); + const result = spawnSync("bash", ["-s", "--", "--help"], { cwd: tmp, input: scriptContents, encoding: "utf-8", env: { ...process.env, HOME: tmp, - PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, - NEMOCLAW_NON_INTERACTIVE: "1", - NPM_PREFIX: prefix, + PATH: TEST_SYSTEM_PATH, }, }); const output = `${result.stdout}${result.stderr}`; expect(result.status).toBe(0); - expect(output).toMatch(/Installation complete!/); - expect(output).toMatch(/nemoclaw v0\.1\.0-test is ready/); + expect(output).toMatch(/NemoClaw Installer/); + expect(output).not.toMatch(/deprecated compatibility wrapper/); }); it("--help exits 0 and shows install usage", () => { @@ -502,7 +360,9 @@ exit 0 }); expect(result.status).toBe(0); - expect(`${result.stdout}${result.stderr}`).toMatch(/nemoclaw-installer v\d+\.\d+\.\d+/); + const output = `${result.stdout}${result.stderr}`; + expect(output.trim()).toMatch(/^nemoclaw-installer(?: v\d+\.\d+\.\d+(?:-.+)?)?$/); + expect(output).not.toMatch(/0\.1\.0/); }); it("-v exits 0 and prints the version number", () => { @@ -512,7 +372,35 @@ exit 0 }); expect(result.status).toBe(0); - expect(`${result.stdout}${result.stderr}`).toMatch(/nemoclaw-installer v\d+\.\d+\.\d+/); + const output = `${result.stdout}${result.stderr}`; + expect(output.trim()).toMatch(/^nemoclaw-installer(?: v\d+\.\d+\.\d+(?:-.+)?)?$/); + expect(output).not.toMatch(/0\.1\.0/); + }); + + it("piped --help does not show the placeholder installer version", () => { + const result = spawnSync("bash", ["-s", "--", "--help"], { + cwd: os.tmpdir(), + encoding: "utf-8", + input: fs.readFileSync(INSTALLER, "utf-8"), + }); + + expect(result.status).toBe(0); + const output = `${result.stdout}${result.stderr}`; + expect(output).toMatch(/NemoClaw Installer/); + expect(output).not.toMatch(/0\.1\.0/); + }); + + it("piped --version omits the placeholder installer version", () => { + const result = spawnSync("bash", ["-s", "--", "--version"], { + cwd: os.tmpdir(), + encoding: "utf-8", + input: fs.readFileSync(INSTALLER, "utf-8"), + }); + + expect(result.status).toBe(0); + const output = `${result.stdout}${result.stderr}`; + expect(output.trim()).toBe("nemoclaw-installer"); + expect(output).not.toMatch(/0\.1\.0/); }); it("uses npm install + npm link for a source checkout (no -g)", () => { @@ -534,7 +422,7 @@ if [ "$1" = "pack" ]; then exit 0 fi if [ "$1" = "install" ]; then exit 0; fi -if [ "$1" = "run" ] && [ "$2" = "build" ]; then exit 0; fi +if [ "$1" = "run" ] && { [ "$2" = "build" ] || [ "$2" = "build:cli" ] || [ "$2" = "--if-present" ]; }; then exit 0; fi if [ "$1" = "link" ]; then cat > "$NPM_PREFIX/bin/nemoclaw" <<'EOS' #!/usr/bin/env bash @@ -566,6 +454,7 @@ fi`, HOME: tmp, PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", NPM_PREFIX: prefix, NPM_LOG_PATH: npmLog, }, @@ -580,39 +469,52 @@ fi`, expect(log).not.toMatch(new RegExp(GITHUB_INSTALL_URL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); }); - it("spin() non-TTY: dumps wrapped-command output and exits non-zero on failure", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-spin-fail-")); + it("auto-resumes an interrupted onboarding session during install", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-resume-")); const fakeBin = path.join(tmp, "bin"); const prefix = path.join(tmp, "prefix"); + const onboardLog = path.join(tmp, "onboard.log"); fs.mkdirSync(fakeBin); fs.mkdirSync(path.join(prefix, "bin"), { recursive: true }); + fs.mkdirSync(path.join(tmp, ".nemoclaw"), { recursive: true }); - writeNodeStub(fakeBin); - writeExecutable( - path.join(fakeBin, "git"), - `#!/usr/bin/env bash -if [ "$1" = "clone" ]; then - target="\${@: -1}" - mkdir -p "$target/nemoclaw" - echo '{"name":"nemoclaw","version":"0.1.0","dependencies":{"openclaw":"2026.3.11"}}' > "$target/package.json" - echo '{"name":"nemoclaw-plugin","version":"0.1.0"}' > "$target/nemoclaw/package.json" - exit 0 -fi -exit 0 -`, + fs.writeFileSync( + path.join(tmp, ".nemoclaw", "onboard-session.json"), + JSON.stringify({ resumable: true, status: "in_progress" }, null, 2), ); + + writeNodeStub(fakeBin); writeNpmStub( fakeBin, `if [ "$1" = "pack" ]; then - echo "ENOTFOUND simulated network error" >&2 - exit 1 + tmpdir="$4" + mkdir -p "$tmpdir/package" + tar -czf "$tmpdir/openclaw-2026.3.11.tgz" -C "$tmpdir" package + exit 0 fi -if [ "$1" = "install" ] || [ "$1" = "run" ] || [ "$1" = "link" ]; then - echo "ENOTFOUND simulated network error" >&2 - exit 1 +if [ "$1" = "install" ]; then exit 0; fi +if [ "$1" = "run" ] && { [ "$2" = "build" ] || [ "$2" = "build:cli" ] || [ "$2" = "--if-present" ]; }; then exit 0; fi +if [ "$1" = "link" ]; then + cat > "$NPM_PREFIX/bin/nemoclaw" <<'EOS' +#!/usr/bin/env bash +printf '%s\\n' "$*" >> "$NEMOCLAW_ONBOARD_LOG" +exit 0 +EOS + chmod +x "$NPM_PREFIX/bin/nemoclaw" + exit 0 fi`, ); + fs.writeFileSync( + path.join(tmp, "package.json"), + JSON.stringify({ name: "nemoclaw", version: "0.1.0" }, null, 2), + ); + fs.mkdirSync(path.join(tmp, "nemoclaw"), { recursive: true }); + fs.writeFileSync( + path.join(tmp, "nemoclaw", "package.json"), + JSON.stringify({ name: "nemoclaw-plugin", version: "0.1.0" }, null, 2), + ); + const result = spawnSync("bash", [INSTALLER], { cwd: tmp, encoding: "utf-8", @@ -621,39 +523,204 @@ fi`, HOME: tmp, PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", NPM_PREFIX: prefix, + NEMOCLAW_ONBOARD_LOG: onboardLog, }, }); - expect(result.status).not.toBe(0); - expect(`${result.stdout}${result.stderr}`).toMatch(/ENOTFOUND simulated network error/); + expect(result.status).toBe(0); + expect(`${result.stdout}${result.stderr}`).toMatch( + /Found an interrupted onboarding session — resuming it\./, + ); + expect(fs.readFileSync(onboardLog, "utf-8")).toMatch( + /^onboard --resume --non-interactive --yes-i-accept-third-party-software$/m, + ); }); - it("creates a user-local shim when npm installs outside the current PATH", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-shim-")); + it("requires explicit terms acceptance in non-interactive install mode", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-terms-required-")); const fakeBin = path.join(tmp, "bin"); const prefix = path.join(tmp, "prefix"); + const onboardLog = path.join(tmp, "onboard.log"); fs.mkdirSync(fakeBin); fs.mkdirSync(path.join(prefix, "bin"), { recursive: true }); - fs.mkdirSync(path.join(tmp, ".local"), { recursive: true }); - writeExecutable( - path.join(fakeBin, "node"), - `#!/usr/bin/env bash -if [ "$1" = "-v" ] || [ "$1" = "--version" ]; then - echo "v22.14.0" + writeNodeStub(fakeBin); + writeNpmStub( + fakeBin, + `if [ "$1" = "pack" ]; then + tmpdir="$4" + mkdir -p "$tmpdir/package" + tar -czf "$tmpdir/openclaw-2026.3.11.tgz" -C "$tmpdir" package exit 0 fi -if [ "$1" = "-e" ]; then - exit 1 -fi -exit 99 -`, - ); - - writeExecutable( +if [ "$1" = "install" ]; then exit 0; fi +if [ "$1" = "run" ] && { [ "$2" = "build" ] || [ "$2" = "build:cli" ] || [ "$2" = "--if-present" ]; }; then exit 0; fi +if [ "$1" = "link" ]; then + cat > "$NPM_PREFIX/bin/nemoclaw" <<'EOS' +#!/usr/bin/env bash +printf '%s\\n' "$*" >> "$NEMOCLAW_ONBOARD_LOG" +exit 0 +EOS + chmod +x "$NPM_PREFIX/bin/nemoclaw" + exit 0 +fi`, + ); + + const result = spawnSync("bash", [INSTALLER, "--non-interactive"], { + cwd: path.join(import.meta.dirname, ".."), + encoding: "utf-8", + env: { + ...process.env, + HOME: tmp, + PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, + NPM_PREFIX: prefix, + NEMOCLAW_ONBOARD_LOG: onboardLog, + }, + }); + + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toMatch(/--yes-i-accept-third-party-software/); + expect(fs.existsSync(onboardLog)).toBe(false); + }); + + it("passes the acceptance flag through to non-interactive onboard", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-terms-accept-")); + const fakeBin = path.join(tmp, "bin"); + const prefix = path.join(tmp, "prefix"); + const onboardLog = path.join(tmp, "onboard.log"); + fs.mkdirSync(fakeBin); + fs.mkdirSync(path.join(prefix, "bin"), { recursive: true }); + + writeNodeStub(fakeBin); + writeNpmStub( + fakeBin, + `if [ "$1" = "pack" ]; then + tmpdir="$4" + mkdir -p "$tmpdir/package" + tar -czf "$tmpdir/openclaw-2026.3.11.tgz" -C "$tmpdir" package + exit 0 +fi +if [ "$1" = "install" ]; then exit 0; fi +if [ "$1" = "run" ] && { [ "$2" = "build" ] || [ "$2" = "build:cli" ] || [ "$2" = "--if-present" ]; }; then exit 0; fi +if [ "$1" = "link" ]; then + cat > "$NPM_PREFIX/bin/nemoclaw" <<'EOS' +#!/usr/bin/env bash +printf '%s\\n' "$*" >> "$NEMOCLAW_ONBOARD_LOG" +exit 0 +EOS + chmod +x "$NPM_PREFIX/bin/nemoclaw" + exit 0 +fi`, + ); + + const result = spawnSync( + "bash", + [INSTALLER, "--non-interactive", "--yes-i-accept-third-party-software"], + { + cwd: path.join(import.meta.dirname, ".."), + encoding: "utf-8", + env: { + ...process.env, + HOME: tmp, + PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, + NPM_PREFIX: prefix, + NEMOCLAW_ONBOARD_LOG: onboardLog, + }, + }, + ); + + expect(result.status).toBe(0); + expect(fs.readFileSync(onboardLog, "utf-8")).toMatch( + /^onboard --non-interactive --yes-i-accept-third-party-software$/m, + ); + }); + + it("spin() non-TTY: dumps wrapped-command output and exits non-zero on failure", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-spin-fail-")); + const fakeBin = path.join(tmp, "bin"); + const prefix = path.join(tmp, "prefix"); + fs.mkdirSync(fakeBin); + fs.mkdirSync(path.join(prefix, "bin"), { recursive: true }); + + writeNodeStub(fakeBin); + writeExecutable( + path.join(fakeBin, "git"), + `#!/usr/bin/env bash +if [ "\${1:-}" = "-c" ]; then + shift 2 +fi +if [ "$1" = "clone" ]; then + target="\${@: -1}" + mkdir -p "$target/nemoclaw" + echo '{"name":"nemoclaw","version":"0.1.0","dependencies":{"openclaw":"2026.3.11"}}' > "$target/package.json" + echo '{"name":"nemoclaw-plugin","version":"0.1.0"}' > "$target/nemoclaw/package.json" + exit 0 +fi +exit 0 +`, + ); + writeNpmStub( + fakeBin, + `if [ "$1" = "pack" ]; then + echo "ENOTFOUND simulated network error" >&2 + exit 1 +fi +if [ "$1" = "install" ] || [ "$1" = "run" ] || [ "$1" = "link" ]; then + echo "ENOTFOUND simulated network error" >&2 + exit 1 +fi`, + ); + + const result = spawnSync("bash", [INSTALLER], { + cwd: tmp, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmp, + PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, + NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", + NPM_PREFIX: prefix, + }, + }); + + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toMatch(/ENOTFOUND simulated network error/); + }); + + it("creates a user-local shim when npm installs outside the current PATH", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-shim-")); + const fakeBin = path.join(tmp, "bin"); + const prefix = path.join(tmp, "prefix"); + fs.mkdirSync(fakeBin); + fs.mkdirSync(path.join(prefix, "bin"), { recursive: true }); + fs.mkdirSync(path.join(tmp, ".local"), { recursive: true }); + + writeExecutable( + path.join(fakeBin, "node"), + `#!/usr/bin/env bash +if [ "$1" = "-v" ] || [ "$1" = "--version" ]; then + echo "v22.16.0" + exit 0 +fi +if [ -n "\${1:-}" ] && [ -f "$1" ]; then + exec ${JSON.stringify(process.execPath)} "$@" +fi +if [ "$1" = "-e" ]; then + exit 1 +fi +exit 99 +`, + ); + + writeExecutable( path.join(fakeBin, "git"), `#!/usr/bin/env bash +if [ "\${1:-}" = "-c" ]; then + shift 2 +fi if [ "$1" = "clone" ]; then target="\${@: -1}" mkdir -p "$target/nemoclaw" @@ -735,6 +802,7 @@ exit 0 HOME: tmp, PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", NPM_PREFIX: prefix, }, }); @@ -744,4 +812,993 @@ exit 0 expect(fs.readlinkSync(shimPath)).toBe(path.join(prefix, "bin", "nemoclaw")); expect(`${result.stdout}${result.stderr}`).toMatch(/Created user-local shim/); }); + + it("shows source hint even when bin dir is already in PATH (stale hash protection)", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-ready-shell-")); + const fakeBin = path.join(tmp, "bin"); + const prefix = path.join(tmp, "prefix"); + const prefixBin = path.join(prefix, "bin"); + const nvmDir = path.join(tmp, ".nvm"); + fs.mkdirSync(fakeBin); + fs.mkdirSync(prefixBin, { recursive: true }); + fs.mkdirSync(nvmDir, { recursive: true }); + fs.writeFileSync(path.join(nvmDir, "nvm.sh"), "# stub nvm\n"); + + writeExecutable( + path.join(fakeBin, "node"), + `#!/usr/bin/env bash +if [ "$1" = "-v" ] || [ "$1" = "--version" ]; then + echo "v22.16.0" + exit 0 +fi +if [ -n "\${1:-}" ] && [ -f "$1" ]; then + exec ${JSON.stringify(process.execPath)} "$@" +fi +if [ "$1" = "-e" ]; then + exit 1 +fi +exit 99 +`, + ); + + writeExecutable( + path.join(fakeBin, "git"), + `#!/usr/bin/env bash +if [ "\${1:-}" = "-c" ]; then + shift 2 +fi +if [ "$1" = "clone" ]; then + target="\${@: -1}" + mkdir -p "$target/nemoclaw" + echo '{"name":"nemoclaw","version":"0.1.0","dependencies":{"openclaw":"2026.3.11"}}' > "$target/package.json" + echo '{"name":"nemoclaw-plugin","version":"0.1.0"}' > "$target/nemoclaw/package.json" + exit 0 +fi +exit 0 +`, + ); + + writeExecutable( + path.join(fakeBin, "npm"), + `#!/usr/bin/env bash +set -euo pipefail +if [ "$1" = "--version" ]; then + echo "10.9.2" + exit 0 +fi +if [ "$1" = "config" ] && [ "$2" = "get" ] && [ "$3" = "prefix" ]; then + echo "$NPM_PREFIX" + exit 0 +fi +if [ "$1" = "pack" ]; then + exit 1 +fi +if [ "$1" = "install" ] && [[ "$*" == *"--ignore-scripts"* ]]; then + exit 0 +fi +if [ "$1" = "run" ]; then + exit 0 +fi +if [ "$1" = "link" ]; then + cat > "$NPM_PREFIX/bin/nemoclaw" <<'EOS' +#!/usr/bin/env bash +if [ "$1" = "onboard" ] || [ "$1" = "--version" ]; then + exit 0 +fi +exit 0 +EOS + chmod +x "$NPM_PREFIX/bin/nemoclaw" + exit 0 +fi +echo "unexpected npm invocation: $*" >&2 +exit 98 +`, + ); + + writeExecutable( + path.join(fakeBin, "docker"), + `#!/usr/bin/env bash +if [ "$1" = "info" ]; then + exit 0 +fi +exit 0 +`, + ); + + writeExecutable( + path.join(fakeBin, "openshell"), + `#!/usr/bin/env bash +if [ "$1" = "--version" ]; then + echo "openshell 0.0.9" + exit 0 +fi +exit 0 +`, + ); + + const result = spawnSync("bash", [INSTALLER], { + cwd: tmp, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmp, + PATH: `${fakeBin}:${prefixBin}:${TEST_SYSTEM_PATH}`, + NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", + NPM_PREFIX: prefix, + NVM_DIR: nvmDir, + }, + }); + + const output = `${result.stdout}${result.stderr}`; + expect(result.status).toBe(0); + expect(output).not.toMatch(/current shell cannot resolve 'nemoclaw'/); + // Always show source hint — the parent shell may have stale hash-table + // entries after an upgrade/reinstall even when the dir is in PATH. + expect(output).toMatch(/\$ source /); + }); + + it("shows shell reload hint when PATH was extended by the installer", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-reload-hint-")); + const fakeBin = path.join(tmp, "bin"); + const prefix = path.join(tmp, "prefix"); + const nvmDir = path.join(tmp, ".nvm"); + fs.mkdirSync(fakeBin); + fs.mkdirSync(path.join(prefix, "bin"), { recursive: true }); + fs.mkdirSync(nvmDir, { recursive: true }); + fs.writeFileSync(path.join(nvmDir, "nvm.sh"), "# stub nvm\n"); + + writeNodeStub(fakeBin); + writeNpmStub( + fakeBin, + `if [ "$1" = "pack" ]; then exit 1; fi +if [ "$1" = "install" ] || [ "$1" = "run" ]; then exit 0; fi +if [ "$1" = "link" ]; then + cat > "$NPM_PREFIX/bin/nemoclaw" <<'EOS' +#!/usr/bin/env bash +if [ "$1" = "onboard" ] || [ "$1" = "--version" ]; then exit 0; fi +exit 0 +EOS + chmod +x "$NPM_PREFIX/bin/nemoclaw" + exit 0 +fi`, + ); + + fs.writeFileSync( + path.join(tmp, "package.json"), + JSON.stringify({ name: "nemoclaw", version: "0.1.0" }, null, 2), + ); + fs.mkdirSync(path.join(tmp, "nemoclaw"), { recursive: true }); + fs.writeFileSync( + path.join(tmp, "nemoclaw", "package.json"), + JSON.stringify({ name: "nemoclaw-plugin", version: "0.1.0" }, null, 2), + ); + + const result = spawnSync("bash", [INSTALLER], { + cwd: tmp, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmp, + PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, + NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", + NPM_PREFIX: prefix, + NVM_DIR: nvmDir, + }, + }); + + const output = `${result.stdout}${result.stderr}`; + expect(result.status).toBe(0); + expect(output).toMatch(/\$ source /); + expect(output).not.toContain("Onboarding has not run yet."); + expect(output).not.toContain( + "Onboarding did not run because this shell cannot resolve 'nemoclaw' yet.", + ); + expect(output).toMatch(/\$ nemoclaw my-assistant connect/); + }); +}); + +// --------------------------------------------------------------------------- +// Release-tag resolution — install.sh should clone the latest GitHub release +// tag instead of defaulting to main. +// --------------------------------------------------------------------------- + +describe("installer release-tag resolution", () => { + /** + * Helper: call resolve_release_tag() in isolation by sourcing install.sh. + * Requires the source guard so that main() doesn't run on source. + * `fakeBin` must contain a `curl` stub (and optionally `node`). + */ + function callResolveReleaseTag(fakeBin, env = {}) { + return spawnSync("bash", ["-c", `source "${INSTALLER}" 2>/dev/null; resolve_release_tag`], { + cwd: path.join(import.meta.dirname, ".."), + encoding: "utf-8", + env: { + HOME: os.tmpdir(), + PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, + ...env, + }, + }); + } + + it("defaults to 'latest' with no env override", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-resolve-tag-default-")); + const fakeBin = path.join(tmp, "bin"); + fs.mkdirSync(fakeBin); + + writeExecutable(path.join(fakeBin, "node"), "#!/usr/bin/env bash\nexit 1"); + + const result = callResolveReleaseTag(fakeBin); + + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe("latest"); + }); + + it("uses NEMOCLAW_INSTALL_TAG override", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-resolve-tag-override-")); + const fakeBin = path.join(tmp, "bin"); + fs.mkdirSync(fakeBin); + + // curl stub that would fail — must NOT be called + writeExecutable( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +echo "curl should not be called" >&2 +exit 99`, + ); + writeExecutable(path.join(fakeBin, "node"), "#!/usr/bin/env bash\nexit 1"); + + const result = callResolveReleaseTag(fakeBin, { + NEMOCLAW_INSTALL_TAG: "v0.2.0", + }); + + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe("v0.2.0"); + }); + + it("source-checkout path does NOT call resolve_release_tag / git clone", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-source-notag-")); + const fakeBin = path.join(tmp, "bin"); + const prefix = path.join(tmp, "prefix"); + const gitLog = path.join(tmp, "git.log"); + fs.mkdirSync(fakeBin); + fs.mkdirSync(path.join(prefix, "bin"), { recursive: true }); + + writeNodeStub(fakeBin); + writeNpmStub( + fakeBin, + `if [ "$1" = "pack" ]; then exit 1; fi +if [ "$1" = "install" ] || [ "$1" = "run" ]; then exit 0; fi +if [ "$1" = "link" ]; then + cat > "$NPM_PREFIX/bin/nemoclaw" <<'EOS' +#!/usr/bin/env bash +if [ "$1" = "onboard" ] || [ "$1" = "--version" ]; then exit 0; fi +exit 0 +EOS + chmod +x "$NPM_PREFIX/bin/nemoclaw" + exit 0 +fi`, + ); + + // curl stub that would fail — must NOT be called + writeExecutable( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +echo "curl should not be called for source checkout" >&2 +exit 99`, + ); + + writeExecutable( + path.join(fakeBin, "git"), + `#!/usr/bin/env bash +printf '%s\\n' "$*" >> "$GIT_LOG_PATH" +exit 0`, + ); + + // Write package.json that triggers source-checkout path + fs.writeFileSync( + path.join(tmp, "package.json"), + JSON.stringify( + { name: "nemoclaw", version: "0.1.0", dependencies: { openclaw: "2026.3.11" } }, + null, + 2, + ), + ); + fs.mkdirSync(path.join(tmp, "nemoclaw"), { recursive: true }); + fs.writeFileSync( + path.join(tmp, "nemoclaw", "package.json"), + JSON.stringify({ name: "nemoclaw-plugin", version: "0.1.0" }, null, 2), + ); + + const result = spawnSync("bash", [INSTALLER], { + cwd: tmp, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmp, + PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, + NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", + NPM_PREFIX: prefix, + GIT_LOG_PATH: gitLog, + }, + }); + + expect(result.status).toBe(0); + // git clone / git fetch should NOT have been called in the source-checkout path. + // git may be called for version resolution (git describe), so we check + // that no clone or fetch was attempted rather than no git calls at all. + if (fs.existsSync(gitLog)) { + const gitCalls = fs.readFileSync(gitLog, "utf-8"); + expect(gitCalls).not.toMatch(/clone/); + expect(gitCalls).not.toMatch(/fetch/); + } + // And curl for the releases API should NOT have been called + expect(`${result.stdout}${result.stderr}`).not.toMatch(/curl should not be called/); + }); + + it("repo-checkout install does not clone a separate ref even when cwd is elsewhere", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-tag-e2e-")); + const fakeBin = path.join(tmp, "bin"); + const prefix = path.join(tmp, "prefix"); + const gitLog = path.join(tmp, "git.log"); + fs.mkdirSync(fakeBin); + fs.mkdirSync(path.join(prefix, "bin"), { recursive: true }); + + writeNodeStub(fakeBin); + + writeExecutable( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +/usr/bin/curl "$@"`, + ); + + writeExecutable( + path.join(fakeBin, "git"), + `#!/usr/bin/env bash +printf '%s\\n' "$*" >> "$GIT_LOG_PATH" +if [ "\${1:-}" = "-c" ]; then + shift 2 +fi +if [ "$1" = "clone" ]; then + target="\${@: -1}" + mkdir -p "$target/nemoclaw" + echo '{"name":"nemoclaw","version":"0.5.0","dependencies":{"openclaw":"2026.3.11"}}' > "$target/package.json" + echo '{"name":"nemoclaw-plugin","version":"0.5.0"}' > "$target/nemoclaw/package.json" + exit 0 +fi +exit 0`, + ); + + writeNpmStub( + fakeBin, + `if [ "$1" = "pack" ]; then exit 1; fi +if [ "$1" = "install" ] || [ "$1" = "run" ]; then exit 0; fi +if [ "$1" = "link" ]; then + cat > "$NPM_PREFIX/bin/nemoclaw" <<'EOS' +#!/usr/bin/env bash +if [ "$1" = "onboard" ] || [ "$1" = "--version" ]; then exit 0; fi +exit 0 +EOS + chmod +x "$NPM_PREFIX/bin/nemoclaw" + exit 0 +fi`, + ); + + const result = spawnSync("bash", [INSTALLER], { + cwd: tmp, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmp, + PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, + NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", + NPM_PREFIX: prefix, + GIT_LOG_PATH: gitLog, + }, + }); + + expect(result.status).toBe(0); + const gitCalls = fs.readFileSync(gitLog, "utf-8"); + expect(gitCalls).not.toMatch(/clone/); + expect(gitCalls).not.toMatch(/fetch/); + }); +}); + +// --------------------------------------------------------------------------- +// Pure helper functions — sourced and tested in isolation. +// --------------------------------------------------------------------------- + +describe("installer pure helpers", () => { + /** + * Helper: source install.sh and call a function, returning stdout. + */ + function callInstallerFn(fnCall, env = {}) { + return spawnSync("bash", ["-c", `source "${INSTALLER}" 2>/dev/null; ${fnCall}`], { + cwd: path.join(import.meta.dirname, ".."), + encoding: "utf-8", + env: { + HOME: os.tmpdir(), + PATH: TEST_SYSTEM_PATH, + ...env, + }, + }); + } + + // -- version_gte -- + + it("version_gte: equal versions return 0", () => { + const r = callInstallerFn('version_gte "1.2.3" "1.2.3" && echo yes || echo no'); + expect(r.stdout.trim()).toBe("yes"); + }); + + it("version_gte: higher major returns 0", () => { + const r = callInstallerFn('version_gte "2.0.0" "1.9.9" && echo yes || echo no'); + expect(r.stdout.trim()).toBe("yes"); + }); + + it("version_gte: lower major returns 1", () => { + const r = callInstallerFn('version_gte "0.17.0" "0.18.0" && echo yes || echo no'); + expect(r.stdout.trim()).toBe("no"); + }); + + it("version_gte: higher minor returns 0", () => { + const r = callInstallerFn('version_gte "0.19.0" "0.18.0" && echo yes || echo no'); + expect(r.stdout.trim()).toBe("yes"); + }); + + it("version_gte: higher patch returns 0", () => { + const r = callInstallerFn('version_gte "0.18.1" "0.18.0" && echo yes || echo no'); + expect(r.stdout.trim()).toBe("yes"); + }); + + it("version_gte: lower patch returns 1", () => { + const r = callInstallerFn('version_gte "0.18.0" "0.18.1" && echo yes || echo no'); + expect(r.stdout.trim()).toBe("no"); + }); + + // -- version_major -- + + it("version_major: strips v prefix", () => { + const r = callInstallerFn('version_major "v22.14.0"'); + expect(r.stdout.trim()).toBe("22"); + }); + + it("version_major: works without v prefix", () => { + const r = callInstallerFn('version_major "10.9.2"'); + expect(r.stdout.trim()).toBe("10"); + }); + + it("version_major: single digit", () => { + const r = callInstallerFn('version_major "v8"'); + expect(r.stdout.trim()).toBe("8"); + }); + + // -- resolve_installer_version -- + + it("resolve_installer_version: reads version from git or package.json", () => { + const r = callInstallerFn("resolve_installer_version"); + // May return clean semver ("0.0.2") or git describe format ("0.0.2-3-gabcdef1") + expect(r.stdout.trim()).toMatch(/^\d+\.\d+\.\d+(-.+)?$/); + }); + + it("resolve_openclaw_version: falls back to Dockerfile.base when package.json omits it", () => { + const r = callInstallerFn('resolve_openclaw_version "$PWD"'); + expect(r.stdout.trim()).toBe("2026.3.11"); + }); + + it("resolve_installer_version: falls back to package.json when git tags are unavailable", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-resolve-ver-pkg-")); + fs.mkdirSync(path.join(tmp, ".git")); + fs.writeFileSync( + path.join(tmp, "package.json"), + `${JSON.stringify({ version: "0.5.0" }, null, 2)}\n`, + ); + // source overwrites SCRIPT_DIR, so we re-set it after sourcing. + // The temp dir advertises git metadata but has no usable tags, + // so the function should fall back to package.json instead of exiting. + const r = spawnSync( + "bash", + ["-c", `source "${INSTALLER}" 2>/dev/null; SCRIPT_DIR="${tmp}"; resolve_installer_version`], + { + cwd: tmp, + encoding: "utf-8", + env: { HOME: tmp, PATH: TEST_SYSTEM_PATH }, + }, + ); + expect(r.status).toBe(0); + expect(r.stdout.trim()).toBe("0.5.0"); + }); + + it("resolve_installer_version: falls back to DEFAULT when no package.json", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-resolve-ver-")); + // source overwrites SCRIPT_DIR, so we re-set it after sourcing. + // The temp dir has no .git, no .version, and no package.json, + // so the function should fall back to DEFAULT_NEMOCLAW_VERSION. + const r = spawnSync( + "bash", + ["-c", `source "${INSTALLER}" 2>/dev/null; SCRIPT_DIR="${tmp}"; resolve_installer_version`], + { + cwd: tmp, + encoding: "utf-8", + env: { HOME: tmp, PATH: TEST_SYSTEM_PATH }, + }, + ); + expect(r.stdout.trim()).toBe("0.1.0"); + }); + + it("installer_version_for_display: hides the placeholder default", () => { + const r = callInstallerFn( + 'NEMOCLAW_VERSION="$DEFAULT_NEMOCLAW_VERSION"; installer_version_for_display', + ); + expect(r.status).toBe(0); + expect(r.stdout).toBe(""); + }); + + it("installer_version_for_display: formats real versions for display", () => { + const r = callInstallerFn('NEMOCLAW_VERSION="0.0.21"; installer_version_for_display'); + expect(r.status).toBe(0); + expect(r.stdout).toBe(" v0.0.21"); + }); + + // -- resolve_default_sandbox_name -- + + it("resolve_default_sandbox_name: returns 'my-assistant' with no registry", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-sandbox-name-")); + const r = callInstallerFn("resolve_default_sandbox_name", { HOME: tmp }); + expect(r.stdout.trim()).toBe("my-assistant"); + }); + + it("resolve_default_sandbox_name: reads defaultSandbox from registry", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-sandbox-name-reg-")); + const registryDir = path.join(tmp, ".nemoclaw"); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + defaultSandbox: "work-bot", + sandboxes: { "work-bot": {}, "test-bot": {} }, + }), + ); + const r = callInstallerFn("resolve_default_sandbox_name", { + HOME: tmp, + PATH: `${process.env.PATH}`, + }); + expect(r.stdout.trim()).toBe("work-bot"); + }); + + it("resolve_default_sandbox_name: honors NEMOCLAW_SANDBOX_NAME env var", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-sandbox-name-env-")); + const r = callInstallerFn("resolve_default_sandbox_name", { + HOME: tmp, + NEMOCLAW_SANDBOX_NAME: "my-custom-name", + }); + expect(r.stdout.trim()).toBe("my-custom-name"); + }); +}); + +// --------------------------------------------------------------------------- +// main() flag parsing edge cases +// --------------------------------------------------------------------------- + +describe("installer flag parsing", () => { + it("rejects unknown flags with usage + error", () => { + const result = spawnSync("bash", [INSTALLER, "--bogus"], { + cwd: path.join(import.meta.dirname, ".."), + encoding: "utf-8", + }); + + expect(result.status).not.toBe(0); + const output = `${result.stdout}${result.stderr}`; + expect(output).toMatch(/Unknown option: --bogus/); + expect(output).toMatch(/NemoClaw Installer/); // usage was printed + }); + + it("--help shows NEMOCLAW_INSTALL_TAG in environment section", () => { + const result = spawnSync("bash", [INSTALLER, "--help"], { + cwd: path.join(import.meta.dirname, ".."), + encoding: "utf-8", + }); + + expect(result.status).toBe(0); + expect(`${result.stdout}${result.stderr}`).toMatch(/NEMOCLAW_INSTALL_TAG/); + }); +}); + +// --------------------------------------------------------------------------- +// ensure_supported_runtime — missing binary paths +// --------------------------------------------------------------------------- + +describe("installer runtime checks (sourced)", () => { + /** + * Call ensure_supported_runtime() in isolation by sourcing install.sh. + * This avoids triggering install_nodejs() which would download real nvm. + */ + function callEnsureSupportedRuntime(fakeBin, env = {}) { + return spawnSync( + "bash", + ["-c", `source "${INSTALLER}" 2>/dev/null; ensure_supported_runtime`], + { + cwd: path.join(import.meta.dirname, ".."), + encoding: "utf-8", + env: { + HOME: os.tmpdir(), + PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, + ...env, + }, + }, + ); + } + + it("fails with clear message when node is missing entirely", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-no-node-")); + const fakeBin = path.join(tmp, "bin"); + fs.mkdirSync(fakeBin); + + // npm exists but node does not + writeExecutable( + path.join(fakeBin, "npm"), + `#!/usr/bin/env bash +echo "10.9.2"`, + ); + + const result = callEnsureSupportedRuntime(fakeBin); + + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toMatch(/Node\.js was not found on PATH/); + }); + + it("fails with clear message when npm is missing entirely", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-no-npm-")); + const fakeBin = path.join(tmp, "bin"); + fs.mkdirSync(fakeBin); + + writeExecutable( + path.join(fakeBin, "node"), + `#!/usr/bin/env bash +if [ "$1" = "--version" ]; then echo "v22.14.0"; exit 0; fi +exit 0`, + ); + + const result = callEnsureSupportedRuntime(fakeBin); + + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toMatch(/npm was not found on PATH/); + }); + + it("succeeds with acceptable Node.js 22.16 and npm 10", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-runtime-ok-")); + const fakeBin = path.join(tmp, "bin"); + fs.mkdirSync(fakeBin); + + writeExecutable( + path.join(fakeBin, "node"), + `#!/usr/bin/env bash +if [ "$1" = "--version" ]; then echo "v22.16.0"; exit 0; fi +exit 0`, + ); + writeExecutable( + path.join(fakeBin, "npm"), + `#!/usr/bin/env bash +if [ "$1" = "--version" ]; then echo "10.0.0"; exit 0; fi +exit 0`, + ); + + const result = callEnsureSupportedRuntime(fakeBin); + + expect(result.status).toBe(0); + expect(`${result.stdout}${result.stderr}`).toMatch(/Runtime OK/); + }); + + it("rejects Node.js 20 which is below the 22.16 minimum", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-runtime-node20-")); + const fakeBin = path.join(tmp, "bin"); + fs.mkdirSync(fakeBin); + + writeExecutable( + path.join(fakeBin, "node"), + `#!/usr/bin/env bash +if [ "$1" = "--version" ]; then echo "v20.18.0"; exit 0; fi +exit 0`, + ); + writeExecutable( + path.join(fakeBin, "npm"), + `#!/usr/bin/env bash +if [ "$1" = "--version" ]; then echo "10.9.2"; exit 0; fi +exit 0`, + ); + + const result = callEnsureSupportedRuntime(fakeBin); + + expect(result.status).not.toBe(0); + const output = `${result.stdout}${result.stderr}`; + expect(output).toMatch(/Unsupported runtime detected/); + expect(output).toMatch(/v20\.18\.0/); + }); + + it("rejects node that returns a non-numeric version", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-runtime-badver-")); + const fakeBin = path.join(tmp, "bin"); + fs.mkdirSync(fakeBin); + + writeExecutable( + path.join(fakeBin, "node"), + `#!/usr/bin/env bash +if [ "$1" = "--version" ]; then echo "nope"; exit 0; fi +exit 0`, + ); + writeExecutable( + path.join(fakeBin, "npm"), + `#!/usr/bin/env bash +if [ "$1" = "--version" ]; then echo "10.9.2"; exit 0; fi +exit 0`, + ); + + const result = callEnsureSupportedRuntime(fakeBin); + + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toMatch(/Could not determine Node\.js version/); + }); +}); + +// --------------------------------------------------------------------------- +// scripts/install.sh (curl-pipe installer) release-tag resolution +// --------------------------------------------------------------------------- + +describe("curl-pipe installer release-tag resolution", () => { + /** + * Build the full fakeBin environment needed to run scripts/install.sh. + * Unlike install.sh, this script also requires docker, openshell, and + * uname stubs because it runs everything top-to-bottom with no main(). + */ + function buildCurlPipeEnv(tmp, { curlStub, gitStub }) { + const fakeBin = path.join(tmp, "bin"); + const prefix = path.join(tmp, "prefix"); + const gitLog = path.join(tmp, "git.log"); + fs.mkdirSync(fakeBin); + fs.mkdirSync(path.join(prefix, "bin"), { recursive: true }); + + writeExecutable( + path.join(fakeBin, "node"), + `#!/usr/bin/env bash +if [ "$1" = "-v" ] || [ "$1" = "--version" ]; then echo "v22.16.0"; exit 0; fi +if [ -n "\${1:-}" ] && [ -f "$1" ]; then + exec ${JSON.stringify(process.execPath)} "$@" +fi +if [ "$1" = "-e" ]; then exit 1; fi +exit 99`, + ); + + writeExecutable( + path.join(fakeBin, "npm"), + `#!/usr/bin/env bash +set -euo pipefail +if [ "$1" = "--version" ]; then echo "10.9.2"; exit 0; fi +if [ "$1" = "config" ] && [ "$2" = "get" ] && [ "$3" = "prefix" ]; then echo "$NPM_PREFIX"; exit 0; fi +if [ "$1" = "pack" ]; then exit 1; fi +if [ "$1" = "install" ] && [[ "$*" == *"--ignore-scripts"* ]]; then exit 0; fi +if [ "$1" = "run" ]; then exit 0; fi +if [ "$1" = "link" ]; then + cat > "$NPM_PREFIX/bin/nemoclaw" <<'EOS' +#!/usr/bin/env bash +if [ "$1" = "--version" ]; then echo "v0.5.0-test"; exit 0; fi +exit 0 +EOS + chmod +x "$NPM_PREFIX/bin/nemoclaw" + exit 0 +fi +echo "unexpected npm invocation: $*" >&2; exit 98`, + ); + + writeExecutable( + path.join(fakeBin, "docker"), + `#!/usr/bin/env bash +if [ "$1" = "info" ]; then exit 0; fi +exit 0`, + ); + + writeExecutable( + path.join(fakeBin, "openshell"), + `#!/usr/bin/env bash +if [ "$1" = "--version" ]; then echo "openshell 0.0.9"; exit 0; fi +exit 0`, + ); + + writeExecutable(path.join(fakeBin, "curl"), curlStub); + writeExecutable(path.join(fakeBin, "git"), gitStub); + + return { fakeBin, prefix, gitLog }; + } + + it("repo-checkout install ignores release-tag cloning when invoked by path", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-curl-pipe-tag-e2e-")); + const { fakeBin, prefix, gitLog } = buildCurlPipeEnv(tmp, { + curlStub: `#!/usr/bin/env bash +/usr/bin/curl "$@"`, + gitStub: `#!/usr/bin/env bash +printf '%s\\n' "$*" >> "$GIT_LOG_PATH" +if [ "\${1:-}" = "-c" ]; then + shift 2 +fi +if [ "$1" = "clone" ]; then + target="\${@: -1}" + mkdir -p "$target/nemoclaw" + echo '{"name":"nemoclaw","version":"0.5.0","dependencies":{"openclaw":"2026.3.11"}}' > "$target/package.json" + echo '{"name":"nemoclaw-plugin","version":"0.5.0"}' > "$target/nemoclaw/package.json" + exit 0 +fi +exit 0`, + }); + + const result = spawnSync("bash", [CURL_PIPE_INSTALLER], { + cwd: tmp, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmp, + PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, + NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", + NPM_PREFIX: prefix, + GIT_LOG_PATH: gitLog, + }, + }); + + expect(result.status).toBe(0); + const gitCalls = fs.readFileSync(gitLog, "utf-8"); + expect(gitCalls).not.toMatch(/clone/); + expect(gitCalls).not.toMatch(/fetch/); + }); + + it("repo-checkout install ignores NEMOCLAW_INSTALL_TAG when invoked by path", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-curl-pipe-tag-override-")); + const { fakeBin, prefix, gitLog } = buildCurlPipeEnv(tmp, { + curlStub: `#!/usr/bin/env bash +for arg in "$@"; do + if [[ "$arg" == *"api.github.com"* ]]; then + echo "curl should not hit the releases API" >&2 + exit 99 + fi +done +/usr/bin/curl "$@"`, + gitStub: `#!/usr/bin/env bash +printf '%s\\n' "$*" >> "$GIT_LOG_PATH" +if [ "\${1:-}" = "-c" ]; then + shift 2 +fi +if [ "$1" = "clone" ]; then + target="\${@: -1}" + mkdir -p "$target/nemoclaw" + echo '{"name":"nemoclaw","version":"0.2.0","dependencies":{"openclaw":"2026.3.11"}}' > "$target/package.json" + echo '{"name":"nemoclaw-plugin","version":"0.2.0"}' > "$target/nemoclaw/package.json" + exit 0 +fi +exit 0`, + }); + + const result = spawnSync("bash", [CURL_PIPE_INSTALLER], { + cwd: tmp, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmp, + PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, + NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", + NPM_PREFIX: prefix, + GIT_LOG_PATH: gitLog, + NEMOCLAW_INSTALL_TAG: "v0.2.0", + }, + }); + + expect(result.status).toBe(0); + const gitCalls = fs.readFileSync(gitLog, "utf-8"); + expect(gitCalls).not.toMatch(/clone/); + expect(gitCalls).not.toMatch(/fetch/); + expect(`${result.stdout}${result.stderr}`).not.toMatch(/curl should not hit the releases API/); + }); + + it("falls back to the legacy root installer when the selected ref only has the old scripts/install.sh wrapper", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-curl-pipe-legacy-ref-")); + const legacyLog = path.join(tmp, "legacy.log"); + const { fakeBin, prefix } = buildCurlPipeEnv(tmp, { + curlStub: `#!/usr/bin/env bash +/usr/bin/curl "$@"`, + gitStub: `#!/usr/bin/env bash +if [ "\${1:-}" = "-c" ]; then + shift 2 +fi +if [ "$1" = "clone" ]; then + target="\${@: -1}" + mkdir -p "$target/scripts" + cat > "$target/scripts/install.sh" <<'EOS' +#!/usr/bin/env bash +set -euo pipefail +echo legacy-wrapper >&2 +exit 97 +EOS + chmod +x "$target/scripts/install.sh" + cat > "$target/install.sh" <<'EOS' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "\${NEMOCLAW_INSTALL_TAG:-unset}" > "\${LEGACY_LOG_PATH:?}" +EOS + chmod +x "$target/install.sh" + exit 0 +fi +exit 0`, + }); + + const installerInput = fs.readFileSync(CURL_PIPE_INSTALLER, "utf-8"); + const result = spawnSync("bash", [], { + cwd: tmp, + input: installerInput, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmp, + PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, + NEMOCLAW_INSTALL_TAG: "v0.0.1", + NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", + NPM_PREFIX: prefix, + LEGACY_LOG_PATH: legacyLog, + }, + }); + + expect(result.status).toBe(0); + expect(fs.readFileSync(legacyLog, "utf-8")).toMatch(/^v0\.0\.1\s*$/); + }); + + it("resolves the usage notice helper from the cloned source during piped installs", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-curl-pipe-usage-notice-")); + const { fakeBin, prefix } = buildCurlPipeEnv(tmp, { + curlStub: `#!/usr/bin/env bash +/usr/bin/curl "$@"`, + gitStub: `#!/usr/bin/env bash +if [ "\${1:-}" = "-c" ]; then + shift 2 +fi +if [ "$1" = "clone" ]; then + target="\${@: -1}" + mkdir -p "$target/nemoclaw" "$target/bin/lib" "$target/scripts" + echo '{"name":"nemoclaw","version":"0.5.0","dependencies":{"openclaw":"2026.3.11"}}' > "$target/package.json" + echo '{"name":"nemoclaw-plugin","version":"0.5.0"}' > "$target/nemoclaw/package.json" + cat > "$target/bin/lib/usage-notice.js" <<'EOS' +#!/usr/bin/env node +process.exit(0) +EOS + chmod +x "$target/bin/lib/usage-notice.js" + cat > "$target/scripts/install.sh" <<'EOS' +#!/usr/bin/env bash +set -euo pipefail +# NEMOCLAW_VERSIONED_INSTALLER_PAYLOAD=1 +node "$NEMOCLAW_REPO_ROOT/bin/lib/usage-notice.js" +EOS + chmod +x "$target/scripts/install.sh" + exit 0 +fi +exit 0`, + }); + + const installerInput = fs.readFileSync(CURL_PIPE_INSTALLER, "utf-8"); + const result = spawnSync("bash", [], { + cwd: tmp, + input: installerInput, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmp, + PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, + NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", + NPM_PREFIX: prefix, + }, + }); + + expect(result.status).toBe(0); + expect(`${result.stdout}${result.stderr}`).not.toMatch(/Cannot find module .*usage-notice\.js/); + }); }); diff --git a/test/local-inference.test.js b/test/local-inference.test.js deleted file mode 100644 index ec37b5f1f..000000000 --- a/test/local-inference.test.js +++ /dev/null @@ -1,167 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, it, expect } from "vitest"; - -import { - CONTAINER_REACHABILITY_IMAGE, - DEFAULT_OLLAMA_MODEL, - getDefaultOllamaModel, - getLocalProviderBaseUrl, - getLocalProviderContainerReachabilityCheck, - getLocalProviderHealthCheck, - getOllamaModelOptions, - getOllamaProbeCommand, - getOllamaWarmupCommand, - parseOllamaList, - parseOllamaTags, - validateOllamaModel, - validateLocalProvider, -} from "../bin/lib/local-inference"; - -describe("local inference helpers", () => { - it("returns the expected base URL for vllm-local", () => { - expect(getLocalProviderBaseUrl("vllm-local")).toBe("http://host.openshell.internal:8000/v1"); - }); - - it("returns the expected base URL for ollama-local", () => { - expect(getLocalProviderBaseUrl("ollama-local")).toBe("http://host.openshell.internal:11434/v1"); - }); - - it("returns the expected health check command for ollama-local", () => { - expect(getLocalProviderHealthCheck("ollama-local")).toBe("curl -sf http://localhost:11434/api/tags 2>/dev/null"); - }); - - it("returns the expected container reachability command for ollama-local", () => { - expect(getLocalProviderContainerReachabilityCheck("ollama-local")).toBe( - `docker run --rm --add-host host.openshell.internal:host-gateway ${CONTAINER_REACHABILITY_IMAGE} -sf http://host.openshell.internal:11434/api/tags 2>/dev/null` - ); - }); - - it("validates a reachable local provider", () => { - let callCount = 0; - const result = validateLocalProvider("ollama-local", () => { - callCount += 1; - return '{"models":[]}'; - }); - expect(result).toEqual({ ok: true }); - expect(callCount).toBe(2); - }); - - it("returns a clear error when ollama-local is unavailable", () => { - const result = validateLocalProvider("ollama-local", () => ""); - expect(result.ok).toBe(false); - expect(result.message).toMatch(/http:\/\/localhost:11434/); - }); - - it("returns a clear error when ollama-local is not reachable from containers", () => { - let callCount = 0; - const result = validateLocalProvider("ollama-local", () => { - callCount += 1; - return callCount === 1 ? '{"models":[]}' : ""; - }); - expect(result.ok).toBe(false); - expect(result.message).toMatch(/host\.openshell\.internal:11434/); - expect(result.message).toMatch(/0\.0\.0\.0:11434/); - }); - - it("returns a clear error when vllm-local is unavailable", () => { - const result = validateLocalProvider("vllm-local", () => ""); - expect(result.ok).toBe(false); - expect(result.message).toMatch(/http:\/\/localhost:8000/); - }); - - it("parses model names from ollama list output", () => { - expect(parseOllamaList( - [ - "NAME ID SIZE MODIFIED", - "nemotron-3-nano:30b abc123 24 GB 2 hours ago", - "qwen3:32b def456 20 GB 1 day ago", - ].join("\n"), - )).toEqual(["nemotron-3-nano:30b", "qwen3:32b"]); - }); - - it("returns parsed ollama model options when available", () => { - expect( - getOllamaModelOptions(() => "nemotron-3-nano:30b abc 24 GB now\nqwen3:32b def 20 GB now") - ).toEqual(["nemotron-3-nano:30b", "qwen3:32b"]); - }); - - it("parses installed models from Ollama /api/tags output", () => { - expect( - parseOllamaTags( - JSON.stringify({ - models: [ - { name: "nemotron-3-nano:30b" }, - { name: "qwen2.5:7b" }, - ], - }) - ) - ).toEqual(["nemotron-3-nano:30b", "qwen2.5:7b"]); - }); - - it("prefers Ollama /api/tags over parsing the CLI list output", () => { - let call = 0; - expect( - getOllamaModelOptions(() => { - call += 1; - if (call === 1) { - return JSON.stringify({ models: [{ name: "qwen2.5:7b" }] }); - } - return ""; - }) - ).toEqual(["qwen2.5:7b"]); - }); - - it("returns no installed ollama models when list output is empty", () => { - expect(getOllamaModelOptions(() => "")).toEqual([]); - }); - - it("prefers the default ollama model when present", () => { - expect( - getDefaultOllamaModel(() => "qwen3:32b abc 20 GB now\nnemotron-3-nano:30b def 24 GB now") - ).toBe(DEFAULT_OLLAMA_MODEL); - }); - - it("falls back to the first listed ollama model when the default is absent", () => { - expect( - getDefaultOllamaModel(() => "qwen3:32b abc 20 GB now\ngemma3:4b def 3 GB now") - ).toBe("qwen3:32b"); - }); - - it("builds a background warmup command for ollama models", () => { - const command = getOllamaWarmupCommand("nemotron-3-nano:30b"); - expect(command).toMatch(/^nohup curl -s http:\/\/localhost:11434\/api\/generate /); - expect(command).toMatch(/"model":"nemotron-3-nano:30b"/); - expect(command).toMatch(/"keep_alive":"15m"/); - }); - - it("builds a foreground probe command for ollama models", () => { - const command = getOllamaProbeCommand("nemotron-3-nano:30b"); - expect(command).toMatch(/^curl -sS --max-time 120 http:\/\/localhost:11434\/api\/generate /); - expect(command).toMatch(/"model":"nemotron-3-nano:30b"/); - }); - - it("fails ollama model validation when the probe times out or returns nothing", () => { - const result = validateOllamaModel("nemotron-3-nano:30b", () => ""); - expect(result.ok).toBe(false); - expect(result.message).toMatch(/did not answer the local probe in time/); - }); - - it("fails ollama model validation when Ollama returns an error payload", () => { - const result = validateOllamaModel( - "gabegoodhart/minimax-m2.1:latest", - () => JSON.stringify({ error: "model requires more system memory" }), - ); - expect(result.ok).toBe(false); - expect(result.message).toMatch(/requires more system memory/); - }); - - it("passes ollama model validation when the probe returns a normal payload", () => { - const result = validateOllamaModel( - "nemotron-3-nano:30b", - () => JSON.stringify({ model: "nemotron-3-nano:30b", response: "hello", done: true }), - ); - expect(result).toEqual({ ok: true }); - }); -}); diff --git a/test/nemoclaw-cli-recovery.test.js b/test/nemoclaw-cli-recovery.test.js new file mode 100644 index 000000000..841b2dee8 --- /dev/null +++ b/test/nemoclaw-cli-recovery.test.js @@ -0,0 +1,109 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, it } from "vitest"; + +describe("nemoclaw CLI runtime recovery", () => { + it("recovers sandbox status when openshell is only available via the resolved fallback path", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-recovery-")); + const homeLocalBin = path.join(tmpDir, ".local", "bin"); + const stateDir = path.join(tmpDir, "state"); + const registryDir = path.join(tmpDir, ".nemoclaw"); + const openshellPath = path.join(homeLocalBin, "openshell"); + const stateFile = path.join(stateDir, "openshell-state.json"); + + fs.mkdirSync(homeLocalBin, { recursive: true }); + fs.mkdirSync(stateDir, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + defaultSandbox: "my-assistant", + sandboxes: { + "my-assistant": { + name: "my-assistant", + model: "nvidia/nemotron-3-super-120b-a12b", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + }), + { mode: 0o600 }, + ); + fs.writeFileSync(stateFile, JSON.stringify({ statusCalls: 0, sandboxGetCalls: 0 })); + fs.writeFileSync( + openshellPath, + `#!${process.execPath} +const fs = require("fs"); +const path = require("path"); +const statePath = ${JSON.stringify(stateFile)}; +const args = process.argv.slice(2); +const state = JSON.parse(fs.readFileSync(statePath, "utf8")); + +if (args[0] === "status") { + state.statusCalls += 1; + fs.writeFileSync(statePath, JSON.stringify(state)); + if (state.statusCalls === 1) { + process.stdout.write("Error: × No active gateway\\n"); + } else { + process.stdout.write("Gateway: nemoclaw\\nStatus: Connected\\n"); + } + process.exit(0); +} + +if (args[0] === "gateway" && (args[1] === "start" || args[1] === "select")) { + fs.writeFileSync(statePath, JSON.stringify(state)); + process.exit(0); +} + +if (args[0] === "gateway" && args[1] === "info") { + process.stdout.write("Gateway: nemoclaw\\nGateway endpoint: https://127.0.0.1:8080\\n"); + process.exit(0); +} + +if (args[0] === "sandbox" && args[1] === "get" && args[2] === "my-assistant") { + state.sandboxGetCalls += 1; + fs.writeFileSync(statePath, JSON.stringify(state)); + if (state.sandboxGetCalls === 1) { + process.stdout.write("Error: × transport error\\n ╰─▶ Connection reset by peer (os error 104)\\n"); + process.exit(1); + } + process.stdout.write("Sandbox:\\n\\n Id: abc\\n Name: my-assistant\\n Namespace: openshell\\n Phase: Ready\\n"); + process.exit(0); +} + +if (args[0] === "logs") { + process.exit(0); +} + +process.exit(0); +`, + { mode: 0o755 }, + ); + + const result = spawnSync( + process.execPath, + [path.join(repoRoot, "bin", "nemoclaw.js"), "my-assistant", "status"], + { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: "/usr/bin:/bin", + }, + }, + ); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Recovered NemoClaw gateway runtime via (start|select)/); + assert.match(result.stdout, /Phase: Ready/); + }); +}); diff --git a/test/nemoclaw-start.test.js b/test/nemoclaw-start.test.js index 246b67cb9..c51d26eaf 100644 --- a/test/nemoclaw-start.test.js +++ b/test/nemoclaw-start.test.js @@ -15,4 +15,116 @@ describe("nemoclaw-start non-root fallback", () => { expect(src).toMatch(/touch \/tmp\/gateway\.log/); expect(src).toMatch(/nohup "\$OPENCLAW" gateway run >\/tmp\/gateway\.log 2>&1 &/); }); + + it("exits on config integrity failure in non-root mode", () => { + const src = fs.readFileSync(START_SCRIPT, "utf-8"); + + // Non-root block must call verify_config_integrity and exit 1 on failure + expect(src).toMatch(/if ! verify_config_integrity; then\s+.*exit 1/s); + // Must not contain the old "proceeding anyway" fallback + expect(src).not.toMatch(/proceeding anyway/i); + }); + + it("calls verify_config_integrity in both root and non-root paths", () => { + const src = fs.readFileSync(START_SCRIPT, "utf-8"); + + // The function must be called at least twice: once in the non-root + // if-block and once in the root path below it. + const calls = src.match(/verify_config_integrity/g) || []; + expect(calls.length).toBeGreaterThanOrEqual(3); // definition + 2 call sites + }); + + it("sends startup diagnostics to stderr so they do not leak into bridge output (#1064)", () => { + const src = fs.readFileSync(START_SCRIPT, "utf-8"); + + expect(src).toContain("echo 'Setting up NemoClaw...' >&2"); + + const nonRootBlock = src.match(/if \[ "\$\(id -u\)" -ne 0 \]; then([\s\S]*?)^fi$/m); + expect(nonRootBlock).toBeTruthy(); + const block = nonRootBlock[1]; + + const echoLines = block.match(/^\s*echo\s+.+$/gm) || []; + expect(echoLines.length).toBeGreaterThan(0); + for (const line of echoLines) { + expect(line).toContain(">&2"); + } + + const dashboardFn = src.match(/print_dashboard_urls\(\) \{([\s\S]*?)^\}/m); + expect(dashboardFn).toBeTruthy(); + const dashboardBody = dashboardFn[1]; + const dashboardEchoes = dashboardBody.match(/^\s*echo\s+.+$/gm) || []; + expect(dashboardEchoes.length).toBeGreaterThan(0); + for (const line of dashboardEchoes) { + expect(line).toContain(">&2"); + } + }); + + it("unwraps the sandbox-create env self-wrapper before building NEMOCLAW_CMD", () => { + const src = fs.readFileSync(START_SCRIPT, "utf-8"); + + expect(src).toContain('if [ "${1:-}" = "env" ]; then'); + expect(src).toContain('export "${_raw_args[$i]}"'); + expect(src).toContain('set -- "${_raw_args[@]:$((_self_wrapper_index + 1))}"'); + }); +}); + +describe("nemoclaw-start auto-pair client whitelisting (#117)", () => { + const src = fs.readFileSync(START_SCRIPT, "utf-8"); + + it("defines ALLOWED_CLIENTS whitelist containing openclaw-control-ui", () => { + expect(src).toMatch(/ALLOWED_CLIENTS\s*=\s*\{.*'openclaw-control-ui'.*\}/); + }); + + it("defines ALLOWED_MODES whitelist containing webchat", () => { + expect(src).toMatch(/ALLOWED_MODES\s*=\s*\{.*'webchat'.*\}/); + }); + + it("rejects devices not in the whitelist", () => { + expect(src).toMatch(/client_id not in ALLOWED_CLIENTS and client_mode not in ALLOWED_MODES/); + expect(src).toMatch(/\[auto-pair\] rejected unknown client=/); + }); + + it("validates device is a dict before accessing fields", () => { + expect(src).toMatch(/if not isinstance\(device, dict\)/); + }); + + it("logs client identity on approval", () => { + expect(src).toMatch(/\[auto-pair\] approved request=\{request_id\} client=\{client_id\}/); + }); + + it("does not unconditionally approve all pending devices", () => { + // The old pattern: `(device or {}).get('requestId')` — approve everything + // Must NOT be present in the auto-pair block + expect(src).not.toMatch(/\(device or \{\}\)\.get\('requestId'\)/); + }); + + it("tracks handled requests to avoid reprocessing rejected devices", () => { + expect(src).toMatch(/HANDLED\s*=\s*set\(\)/); + expect(src).toMatch(/request_id in HANDLED/); + expect(src).toMatch(/HANDLED\.add\(request_id\)/); + }); + + it("documents NEMOCLAW_DISABLE_DEVICE_AUTH as a build-time setting in the script header", () => { + // Must mention it's build-time only — setting at runtime has no effect + // because openclaw.json is baked and immutable + const header = src.split("set -euo pipefail")[0]; + expect(header).toMatch(/NEMOCLAW_DISABLE_DEVICE_AUTH/); + expect(header).toMatch(/build[- ]time/i); + }); + + it("defines ALLOWED_CLIENTS and ALLOWED_MODES outside the poll loop", () => { + // These are constants — they should be defined once alongside HANDLED, + // not reconstructed inside the `if pending:` block every poll cycle + const autoPairBlock = src.match(/PYAUTOPAIR[\s\S]*?PYAUTOPAIR/); + expect(autoPairBlock).toBeTruthy(); + const pyCode = autoPairBlock[0]; + + // ALLOWED_CLIENTS/ALLOWED_MODES should appear BEFORE the `while` loop, + // at the same level as HANDLED, APPROVED, etc. + const allowedClientsPos = pyCode.indexOf("ALLOWED_CLIENTS"); + const whilePos = pyCode.indexOf("while time.time()"); + expect(allowedClientsPos).toBeGreaterThan(-1); + expect(whilePos).toBeGreaterThan(-1); + expect(allowedClientsPos).toBeLessThan(whilePos); + }); }); diff --git a/test/nim.test.js b/test/nim.test.js deleted file mode 100644 index cd4cf6cd4..000000000 --- a/test/nim.test.js +++ /dev/null @@ -1,72 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, it, expect } from "vitest"; -import nim from "../bin/lib/nim"; - -describe("nim", () => { - describe("listModels", () => { - it("returns 5 models", () => { - expect(nim.listModels().length).toBe(5); - }); - - it("each model has name, image, and minGpuMemoryMB", () => { - for (const m of nim.listModels()) { - expect(m.name).toBeTruthy(); - expect(m.image).toBeTruthy(); - expect(typeof m.minGpuMemoryMB === "number").toBeTruthy(); - expect(m.minGpuMemoryMB > 0).toBeTruthy(); - } - }); - }); - - describe("getImageForModel", () => { - it("returns correct image for known model", () => { - expect(nim.getImageForModel("nvidia/nemotron-3-nano-30b-a3b")).toBe("nvcr.io/nim/nvidia/nemotron-3-nano:latest"); - }); - - it("returns null for unknown model", () => { - expect(nim.getImageForModel("bogus/model")).toBe(null); - }); - }); - - describe("containerName", () => { - it("prefixes with nemoclaw-nim-", () => { - expect(nim.containerName("my-sandbox")).toBe("nemoclaw-nim-my-sandbox"); - }); - }); - - describe("detectGpu", () => { - it("returns object or null", () => { - const gpu = nim.detectGpu(); - if (gpu !== null) { - expect(gpu.type).toBeTruthy(); - expect(typeof gpu.count === "number").toBeTruthy(); - expect(typeof gpu.totalMemoryMB === "number").toBeTruthy(); - expect(typeof gpu.nimCapable === "boolean").toBeTruthy(); - } - }); - - it("nvidia type is nimCapable", () => { - const gpu = nim.detectGpu(); - if (gpu && gpu.type === "nvidia") { - expect(gpu.nimCapable).toBe(true); - } - }); - - it("apple type is not nimCapable", () => { - const gpu = nim.detectGpu(); - if (gpu && gpu.type === "apple") { - expect(gpu.nimCapable).toBe(false); - expect(gpu.name).toBeTruthy(); - } - }); - }); - - describe("nimStatus", () => { - it("returns not running for nonexistent container", () => { - const st = nim.nimStatus("nonexistent-test-xyz"); - expect(st.running).toBe(false); - }); - }); -}); diff --git a/test/onboard-readiness.test.js b/test/onboard-readiness.test.js index 049ceed42..9872dcb07 100644 --- a/test/onboard-readiness.test.js +++ b/test/onboard-readiness.test.js @@ -20,17 +20,18 @@ describe("sandbox readiness parsing", () => { }); it("strips ANSI escape codes before matching", () => { - expect(isSandboxReady( - "\x1b[1mmy-assistant\x1b[0m \x1b[32mReady\x1b[0m 2m ago", - "my-assistant" - )).toBeTruthy(); + expect( + isSandboxReady("\x1b[1mmy-assistant\x1b[0m \x1b[32mReady\x1b[0m 2m ago", "my-assistant"), + ).toBeTruthy(); }); it("rejects ANSI-wrapped NotReady", () => { - expect(!isSandboxReady( - "\x1b[1mmy-assistant\x1b[0m \x1b[31mNotReady\x1b[0m crash", - "my-assistant" - )).toBeTruthy(); + expect( + !isSandboxReady( + "\x1b[1mmy-assistant\x1b[0m \x1b[31mNotReady\x1b[0m crash", + "my-assistant", + ), + ).toBeTruthy(); }); it("exact-matches sandbox name in first column", () => { @@ -40,7 +41,7 @@ describe("sandbox readiness parsing", () => { it("does not match sandbox name in non-first column", () => { expect( - !isSandboxReady("other-box Ready owned-by-my-assistant", "my-assistant") + !isSandboxReady("other-box Ready owned-by-my-assistant", "my-assistant"), ).toBeTruthy(); }); @@ -59,13 +60,13 @@ describe("sandbox readiness parsing", () => { it("handles Ready sandbox with extra status columns", () => { expect( - isSandboxReady("my-assistant Ready Running 2m ago 1/1", "my-assistant") + isSandboxReady("my-assistant Ready Running 2m ago 1/1", "my-assistant"), ).toBeTruthy(); }); it("rejects when output only contains name in a URL or path", () => { expect( - !isSandboxReady("Connecting to my-assistant.openshell.internal Ready", "my-assistant") + !isSandboxReady("Connecting to my-assistant.openshell.internal Ready", "my-assistant"), ).toBeTruthy(); // "my-assistant.openshell.internal" is cols[0], not "my-assistant" }); @@ -81,7 +82,7 @@ describe("WSL sandbox name handling", () => { it("buildPolicySetCommand preserves hyphenated sandbox name", () => { const cmd = buildPolicySetCommand("/tmp/policy.yaml", "my-assistant"); expect(cmd.includes("'my-assistant'")).toBeTruthy(); - expect(!cmd.includes(' my-assistant ')).toBeTruthy(); + expect(!cmd.includes(" my-assistant ")).toBeTruthy(); }); it("buildPolicyGetCommand preserves hyphenated sandbox name", () => { diff --git a/test/onboard-selection.test.js b/test/onboard-selection.test.js index 8fceee219..2942e6c44 100644 --- a/test/onboard-selection.test.js +++ b/test/onboard-selection.test.js @@ -8,6 +8,79 @@ import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; +function writeOpenAiStyleAuthRetryCurl(fakeBin, goodToken, models = ["gpt-5.4"]) { + fs.writeFileSync( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +body='{"error":{"message":"forbidden"}}' +status="403" +outfile="" +auth="" +url="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) outfile="$2"; shift 2 ;; + -H) + if echo "$2" | grep -q '^Authorization: Bearer '; then + auth="$2" + fi + shift 2 + ;; + *) url="$1"; shift ;; + esac +done +if echo "$url" | grep -q '/models$'; then + body='{"data":[${models.map((model) => `{"id":"${model}"}`).join(",")}]}' + status="200" +elif echo "$auth" | grep -q '${goodToken}' && echo "$url" | grep -q '/responses$'; then + body='{"id":"resp_123"}' + status="200" +elif echo "$auth" | grep -q '${goodToken}' && echo "$url" | grep -q '/chat/completions$'; then + body='{"id":"chatcmpl-123"}' + status="200" +fi +printf '%s' "$body" > "$outfile" +printf '%s' "$status" +`, + { mode: 0o755 }, + ); +} + +function writeAnthropicStyleAuthRetryCurl(fakeBin, goodToken, models = ["claude-sonnet-4-6"]) { + fs.writeFileSync( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +body='{"error":{"message":"forbidden"}}' +status="403" +outfile="" +auth="" +url="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) outfile="$2"; shift 2 ;; + -H) + if echo "$2" | grep -q '^x-api-key: '; then + auth="$2" + fi + shift 2 + ;; + *) url="$1"; shift ;; + esac +done +if echo "$url" | grep -q '/v1/models$'; then + body='{"data":[${models.map((model) => `{"id":"${model}"}`).join(",")}]}' + status="200" +elif echo "$auth" | grep -q '${goodToken}' && echo "$url" | grep -q '/v1/messages$'; then + body='{"id":"msg_123","content":[{"type":"text","text":"OK"}]}' + status="200" +fi +printf '%s' "$body" > "$outfile" +printf '%s' "$status" +`, + { mode: 0o755 }, + ); +} + describe("onboard provider selection UX", () => { it("prompts explicitly instead of silently auto-selecting detected Ollama", () => { const repoRoot = path.join(import.meta.dirname, ".."); @@ -35,7 +108,7 @@ done printf '%s' "$body" > "$outfile" printf '%s' "$status" `, - { mode: 0o755 } + { mode: 0o755 }, ); const script = String.raw` const credentials = require(${credentialsPath}); @@ -100,14 +173,90 @@ const { setupNim } = require(${onboardPath}); assert.match(payload.messages[0], /Choose \[/); assert.match(payload.messages[1], /Choose model \[1\]/); assert.ok(payload.lines.some((line) => line.includes("Detected local inference option"))); - assert.ok(payload.lines.some((line) => line.includes("Press Enter to keep NVIDIA Endpoints"))); assert.ok(payload.lines.some((line) => line.includes("Cloud models:"))); assert.ok(payload.lines.some((line) => line.includes("Responses API available"))); }); + it("does not label NVIDIA Endpoints as recommended in the provider list", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-no-recommended-label-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "no-recommended-label-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +body='{"id":"ok"}' +status="200" +outfile="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) outfile="$2"; shift 2 ;; + *) shift ;; + esac +done +printf '%s' "$body" > "$outfile" +printf '%s' "$status" +`, + { mode: 0o755 }, + ); + + const script = String.raw` +const credentials = require(${credentialsPath}); +const runner = require(${runnerPath}); + +const messages = []; +credentials.prompt = async (message) => { + messages.push(message); + return ""; +}; +credentials.ensureApiKey = async () => {}; +runner.runCapture = () => ""; + +const { setupNim } = require(${onboardPath}); + +(async () => { + const originalLog = console.log; + const lines = []; + console.log = (...args) => lines.push(args.join(" ")); + try { + await setupNim(null); + originalLog(JSON.stringify({ messages, lines })); + } finally { + console.log = originalLog; + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + }, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.ok(payload.lines.some((line) => line.includes("NVIDIA Endpoints"))); + assert.ok(!payload.lines.some((line) => line.includes("NVIDIA Endpoints (recommended)"))); + }); + it("accepts a manually entered NVIDIA Endpoints model after validating it against /models", () => { const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-build-model-selection-")); + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-onboard-build-model-selection-"), + ); const fakeBin = path.join(tmpDir, "bin"); const scriptPath = path.join(tmpDir, "build-model-selection-check.js"); const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); @@ -134,7 +283,7 @@ fi printf '%s' "$body" > "$outfile" printf '%s' "$status" `, - { mode: 0o755 } + { mode: 0o755 }, ); const script = String.raw` @@ -227,7 +376,7 @@ fi printf '%s' "$body" > "$outfile" printf '%s' "$status" `, - { mode: 0o755 } + { mode: 0o755 }, ); const script = String.raw` @@ -284,8 +433,13 @@ const { setupNim } = require(${onboardPath}); assert.equal(result.status, 0, result.stderr); const payload = JSON.parse(result.stdout.trim()); assert.equal(payload.result.model, "z-ai/glm5"); - assert.equal(payload.messages.filter((message) => /NVIDIA Endpoints model id:/.test(message)).length, 2); - assert.ok(payload.lines.some((line) => line.includes("is not available from NVIDIA Endpoints"))); + assert.equal( + payload.messages.filter((message) => /NVIDIA Endpoints model id:/.test(message)).length, + 2, + ); + assert.ok( + payload.lines.some((line) => line.includes("is not available from NVIDIA Endpoints")), + ); }); it("shows curated Gemini models and supports Other for manual entry", () => { @@ -321,7 +475,7 @@ fi printf '%s' "$body" > "$outfile" printf '%s' "$status" `, - { mode: 0o755 } + { mode: 0o755 }, ); const script = String.raw` @@ -407,7 +561,7 @@ done printf '%s' "$body" > "$outfile" printf '%s' "$status" `, - { mode: 0o755 } + { mode: 0o755 }, ); const script = String.raw` @@ -470,9 +624,13 @@ const { setupNim } = require(${onboardPath}); assert.equal(result.status, 0, result.stderr); const payload = JSON.parse(result.stdout.trim()); assert.equal(payload.result.provider, "ollama-local"); - assert.equal(payload.result.preferredInferenceApi, "openai-responses"); - assert.ok(payload.lines.some((line) => line.includes("Loading Ollama model: nemotron-3-nano:30b"))); - assert.ok(payload.commands.some((command) => command.includes("http://localhost:11434/api/generate"))); + assert.equal(payload.result.preferredInferenceApi, "openai-completions"); + assert.ok( + payload.lines.some((line) => line.includes("Loading Ollama model: nemotron-3-nano:30b")), + ); + assert.ok( + payload.commands.some((command) => command.includes("http://localhost:11434/api/generate")), + ); }); it("offers starter Ollama models when none are installed and pulls the selected model", () => { @@ -501,7 +659,7 @@ done printf '%s' "$body" > "$outfile" printf '%s' "$status" `, - { mode: 0o755 } + { mode: 0o755 }, ); fs.writeFileSync( path.join(fakeBin, "ollama"), @@ -512,7 +670,7 @@ if [ "$1" = "pull" ]; then fi exit 0 `, - { mode: 0o755 } + { mode: 0o755 }, ); const script = String.raw` @@ -572,7 +730,9 @@ const { setupNim } = require(${onboardPath}); assert.equal(payload.result.provider, "ollama-local"); assert.equal(payload.result.model, "qwen2.5:7b"); assert.ok(payload.lines.some((line) => line.includes("Ollama starter models:"))); - assert.ok(payload.lines.some((line) => line.includes("No local Ollama models are installed yet"))); + assert.ok( + payload.lines.some((line) => line.includes("No local Ollama models are installed yet")), + ); assert.ok(payload.lines.some((line) => line.includes("Pulling Ollama model: qwen2.5:7b"))); assert.equal(fs.readFileSync(pullLog, "utf8").trim(), "qwen2.5:7b"); }); @@ -603,7 +763,7 @@ done printf '%s' "$body" > "$outfile" printf '%s' "$status" `, - { mode: 0o755 } + { mode: 0o755 }, ); fs.writeFileSync( path.join(fakeBin, "ollama"), @@ -617,7 +777,7 @@ if [ "$1" = "pull" ]; then fi exit 0 `, - { mode: 0o755 } + { mode: 0o755 }, ); const script = String.raw` @@ -676,8 +836,14 @@ const { setupNim } = require(${onboardPath}); const payload = JSON.parse(result.stdout.trim()); assert.equal(payload.result.provider, "ollama-local"); assert.equal(payload.result.model, "llama3.2:3b"); - assert.ok(payload.lines.some((line) => line.includes("Failed to pull Ollama model 'qwen2.5:7b'"))); - assert.ok(payload.lines.some((line) => line.includes("Choose a different Ollama model or select Other."))); + assert.ok( + payload.lines.some((line) => line.includes("Failed to pull Ollama model 'qwen2.5:7b'")), + ); + assert.ok( + payload.lines.some((line) => + line.includes("Choose a different Ollama model or select Other."), + ), + ); assert.equal(payload.messages.filter((message) => /Ollama model id:/.test(message)).length, 1); assert.equal(fs.readFileSync(pullLog, "utf8").trim(), "qwen2.5:7b\nllama3.2:3b"); }); @@ -713,7 +879,7 @@ fi printf '%s' "$body" > "$outfile" printf '%s' "$status" `, - { mode: 0o755 } + { mode: 0o755 }, ); const script = String.raw` @@ -771,7 +937,9 @@ const { setupNim } = require(${onboardPath}); it("reprompts for an Anthropic Other model when /v1/models validation rejects it", () => { const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-anthropic-model-retry-")); + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-onboard-anthropic-model-retry-"), + ); const fakeBin = path.join(tmpDir, "bin"); const scriptPath = path.join(tmpDir, "anthropic-model-retry-check.js"); const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); @@ -794,7 +962,7 @@ done printf '%s' "$body" > "$outfile" printf '%s' "$status" `, - { mode: 0o755 } + { mode: 0o755 }, ); const script = String.raw` @@ -846,13 +1014,18 @@ const { setupNim } = require(${onboardPath}); assert.equal(result.status, 0, result.stderr); const payload = JSON.parse(result.stdout.trim()); assert.equal(payload.result.model, "claude-haiku-4-5"); - assert.equal(payload.messages.filter((message) => /Anthropic model id:/.test(message)).length, 2); + assert.equal( + payload.messages.filter((message) => /Anthropic model id:/.test(message)).length, + 2, + ); assert.ok(payload.lines.some((line) => line.includes("is not available from Anthropic"))); }); it("returns to provider selection when Anthropic live validation fails interactively", () => { const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-anthropic-validation-retry-")); + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-onboard-anthropic-validation-retry-"), + ); const fakeBin = path.join(tmpDir, "bin"); const scriptPath = path.join(tmpDir, "anthropic-validation-retry-check.js"); const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); @@ -884,7 +1057,7 @@ fi printf '%s' "$body" > "$outfile" printf '%s' "$status" `, - { mode: 0o755 } + { mode: 0o755 }, ); const script = String.raw` @@ -967,14 +1140,14 @@ done printf '%s' "$body" > "$outfile" printf '%s' "$status" `, - { mode: 0o755 } + { mode: 0o755 }, ); const script = String.raw` const credentials = require(${credentialsPath}); const runner = require(${runnerPath}); -const answers = ["5", "https://proxy.example.com", "claude-sonnet-proxy"]; +const answers = ["5", "https://proxy.example.com/v1/messages?token=secret#frag", "claude-sonnet-proxy"]; const messages = []; credentials.prompt = async (message) => { @@ -1059,14 +1232,14 @@ fi printf '%s' "$body" > "$outfile" printf '%s' "$status" `, - { mode: 0o755 } + { mode: 0o755 }, ); const script = String.raw` const credentials = require(${credentialsPath}); const runner = require(${runnerPath}); -const answers = ["3", "https://proxy.example.com/v1", "bad-model", "good-model"]; +const answers = ["3", "https://proxy.example.com/v1/chat/completions?token=secret#frag", "bad-model", "good-model"]; const messages = []; credentials.prompt = async (message) => { @@ -1113,16 +1286,122 @@ const { setupNim } = require(${onboardPath}); assert.equal(payload.result.provider, "compatible-endpoint"); assert.equal(payload.result.model, "good-model"); assert.equal(payload.result.preferredInferenceApi, "openai-responses"); - assert.ok(payload.lines.some((line) => line.includes("Other OpenAI-compatible endpoint endpoint validation failed"))); - assert.ok(payload.lines.some((line) => line.includes("Please enter a different Other OpenAI-compatible endpoint model name."))); - assert.equal(payload.messages.filter((message) => /OpenAI-compatible base URL/.test(message)).length, 1); - assert.equal(payload.messages.filter((message) => /Other OpenAI-compatible endpoint model/.test(message)).length, 2); + assert.ok( + payload.lines.some((line) => + line.includes("Other OpenAI-compatible endpoint endpoint validation failed"), + ), + ); + assert.ok( + payload.lines.some((line) => + line.includes("Please enter a different Other OpenAI-compatible endpoint model name."), + ), + ); + assert.equal( + payload.messages.filter((message) => /OpenAI-compatible base URL/.test(message)).length, + 1, + ); + assert.equal( + payload.messages.filter((message) => /Other OpenAI-compatible endpoint model/.test(message)) + .length, + 2, + ); assert.equal(payload.messages.filter((message) => /Choose \[/.test(message)).length, 1); }); + it("returns to provider selection instead of exiting on blank custom endpoint input", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-onboard-custom-endpoint-blank-"), + ); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "custom-endpoint-blank-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +body='{"id":"ok"}' +status="200" +outfile="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) outfile="$2"; shift 2 ;; + *) shift ;; + esac +done +printf '%s' "$body" > "$outfile" +printf '%s' "$status" +`, + { mode: 0o755 }, + ); + + const script = String.raw` +const credentials = require(${credentialsPath}); +const runner = require(${runnerPath}); + +const answers = ["3", "", "", ""]; +const messages = []; + +credentials.prompt = async (message) => { + messages.push(message); + return answers.shift() || ""; +}; +credentials.ensureApiKey = async () => {}; +runner.runCapture = () => ""; + +const { setupNim } = require(${onboardPath}); + +(async () => { + const originalLog = console.log; + const originalError = console.error; + const lines = []; + console.log = (...args) => lines.push(args.join(" ")); + console.error = (...args) => lines.push(args.join(" ")); + try { + const result = await setupNim(null); + originalLog(JSON.stringify({ result, messages, lines })); + } finally { + console.log = originalLog; + console.error = originalError; + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + }, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.equal(payload.result.provider, "nvidia-prod"); + assert.equal(payload.result.model, "nvidia/nemotron-3-super-120b-a12b"); + assert.ok( + payload.lines.some((line) => + line.includes("Endpoint URL is required for Other OpenAI-compatible endpoint."), + ), + ); + assert.ok(payload.messages.some((message) => /OpenAI-compatible base URL/.test(message))); + assert.ok(payload.messages.filter((message) => /Choose \[1\]/.test(message)).length >= 2); + }); + it("reprompts only for model name when Other Anthropic-compatible endpoint validation fails", () => { const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-custom-anthropic-retry-")); + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-onboard-custom-anthropic-retry-"), + ); const fakeBin = path.join(tmpDir, "bin"); const scriptPath = path.join(tmpDir, "custom-anthropic-retry-check.js"); const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); @@ -1152,14 +1431,14 @@ fi printf '%s' "$body" > "$outfile" printf '%s' "$status" `, - { mode: 0o755 } + { mode: 0o755 }, ); const script = String.raw` const credentials = require(${credentialsPath}); const runner = require(${runnerPath}); -const answers = ["5", "https://proxy.example.com", "bad-claude", "good-claude"]; +const answers = ["5", "https://proxy.example.com/v1/messages?token=secret#frag", "bad-claude", "good-claude"]; const messages = []; credentials.prompt = async (message) => { @@ -1206,18 +1485,34 @@ const { setupNim } = require(${onboardPath}); assert.equal(payload.result.provider, "compatible-anthropic-endpoint"); assert.equal(payload.result.model, "good-claude"); assert.equal(payload.result.preferredInferenceApi, "anthropic-messages"); - assert.ok(payload.lines.some((line) => line.includes("Other Anthropic-compatible endpoint endpoint validation failed"))); - assert.ok(payload.lines.some((line) => line.includes("Please enter a different Other Anthropic-compatible endpoint model name."))); - assert.equal(payload.messages.filter((message) => /Anthropic-compatible base URL/.test(message)).length, 1); - assert.equal(payload.messages.filter((message) => /Other Anthropic-compatible endpoint model/.test(message)).length, 2); + assert.ok( + payload.lines.some((line) => + line.includes("Other Anthropic-compatible endpoint endpoint validation failed"), + ), + ); + assert.ok( + payload.lines.some((line) => + line.includes("Please enter a different Other Anthropic-compatible endpoint model name."), + ), + ); + assert.equal( + payload.messages.filter((message) => /Anthropic-compatible base URL/.test(message)).length, + 1, + ); + assert.equal( + payload.messages.filter((message) => + /Other Anthropic-compatible endpoint model/.test(message), + ).length, + 2, + ); assert.equal(payload.messages.filter((message) => /Choose \[/.test(message)).length, 1); }); - it("returns to provider selection when endpoint validation fails interactively", () => { + it("lets users type back at a lower-level model prompt to return to provider selection", () => { const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-selection-retry-")); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-model-back-")); const fakeBin = path.join(tmpDir, "bin"); - const scriptPath = path.join(tmpDir, "selection-retry-check.js"); + const scriptPath = path.join(tmpDir, "model-back-check.js"); const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); @@ -1226,47 +1521,39 @@ const { setupNim } = require(${onboardPath}); fs.writeFileSync( path.join(fakeBin, "curl"), `#!/usr/bin/env bash -body='{"error":{"message":"bad request"}}' -status="400" +body='{"id":"resp_123"}' +status="200" outfile="" -url="" while [ "$#" -gt 0 ]; do case "$1" in -o) outfile="$2"; shift 2 ;; - *) - url="$1" - shift - ;; + *) shift ;; esac done -if echo "$url" | grep -q 'generativelanguage.googleapis.com' && echo "$url" | grep -q '/responses$'; then - body='{"id":"ok"}' - status="200" -fi printf '%s' "$body" > "$outfile" printf '%s' "$status" `, - { mode: 0o755 } + { mode: 0o755 }, ); const script = String.raw` const credentials = require(${credentialsPath}); const runner = require(${runnerPath}); -const answers = ["2", "", "6", ""]; +const answers = ["3", "https://proxy.example.com/v1", "back", "1", ""]; const messages = []; credentials.prompt = async (message) => { messages.push(message); return answers.shift() || ""; }; +credentials.ensureApiKey = async () => { process.env.NVIDIA_API_KEY = "nvapi-good"; }; runner.runCapture = () => ""; const { setupNim } = require(${onboardPath}); (async () => { - process.env.OPENAI_API_KEY = "sk-test"; - process.env.GEMINI_API_KEY = "gemini-test"; + process.env.COMPATIBLE_API_KEY = "proxy-key"; const originalLog = console.log; const originalError = console.error; const lines = []; @@ -1298,10 +1585,936 @@ const { setupNim } = require(${onboardPath}); assert.equal(result.status, 0, result.stderr); const payload = JSON.parse(result.stdout.trim()); - assert.equal(payload.result.provider, "gemini-api"); - assert.equal(payload.result.preferredInferenceApi, "openai-responses"); - assert.ok(payload.lines.some((line) => line.includes("OpenAI endpoint validation failed"))); - assert.ok(payload.lines.some((line) => line.includes("Please choose a provider/model again"))); + assert.equal(payload.result.provider, "nvidia-prod"); + assert.ok(payload.lines.some((line) => line.includes("Returning to provider selection."))); assert.equal(payload.messages.filter((message) => /Choose \[/.test(message)).length, 2); + assert.equal( + payload.messages.filter((message) => /OpenAI-compatible base URL/.test(message)).length, + 1, + ); + }); + + it("lets users type back after a transport validation failure to return to provider selection", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-transport-back-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "transport-back-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +outfile="" +url="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) outfile="$2"; shift 2 ;; + *) url="$1"; shift ;; + esac +done +if echo "$url" | grep -q 'api.openai.com'; then + printf '%s' 'curl: (6) Could not resolve host: api.openai.com' >&2 + exit 6 +fi +printf '%s' '{"id":"resp_123"}' > "$outfile" +printf '200' +`, + { mode: 0o755 }, + ); + + const script = String.raw` +const credentials = require(${credentialsPath}); +const runner = require(${runnerPath}); + +const answers = ["2", "", "back", "1", ""]; +const messages = []; + +credentials.prompt = async (message) => { + messages.push(message); + return answers.shift() || ""; +}; +credentials.ensureApiKey = async () => { process.env.NVIDIA_API_KEY = "nvapi-good"; }; +runner.runCapture = () => ""; + +const { setupNim } = require(${onboardPath}); + +(async () => { + process.env.OPENAI_API_KEY = "sk-test"; + const originalLog = console.log; + const originalError = console.error; + const lines = []; + console.log = (...args) => lines.push(args.join(" ")); + console.error = (...args) => lines.push(args.join(" ")); + try { + const result = await setupNim(null); + originalLog(JSON.stringify({ result, messages, lines })); + } finally { + console.log = originalLog; + console.error = originalError; + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + }, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.equal(payload.result.provider, "nvidia-prod"); + assert.ok( + payload.lines.some((line) => line.includes("could not resolve the provider hostname")), + ); + assert.ok(payload.lines.some((line) => line.includes("Returning to provider selection."))); + assert.equal( + payload.messages.filter((message) => + /Type 'retry', 'back', or 'exit' \[retry\]: /.test(message), + ).length, + 1, + ); + assert.equal(payload.messages.filter((message) => /Choose \[/.test(message)).length, 2); + }); + + it("returns to provider selection when endpoint validation fails interactively", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-selection-retry-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "selection-retry-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +body='{"error":{"message":"bad request"}}' +status="400" +outfile="" +url="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) outfile="$2"; shift 2 ;; + *) + url="$1" + shift + ;; + esac +done +if echo "$url" | grep -q 'generativelanguage.googleapis.com' && echo "$url" | grep -q '/responses$'; then + body='{"id":"ok"}' + status="200" +fi +printf '%s' "$body" > "$outfile" +printf '%s' "$status" +`, + { mode: 0o755 }, + ); + + const script = String.raw` +const credentials = require(${credentialsPath}); +const runner = require(${runnerPath}); + +const answers = ["2", "", "6", ""]; +const messages = []; + +credentials.prompt = async (message) => { + messages.push(message); + return answers.shift() || ""; +}; +runner.runCapture = () => ""; + +const { setupNim } = require(${onboardPath}); + +(async () => { + process.env.OPENAI_API_KEY = "sk-test"; + process.env.GEMINI_API_KEY = "gemini-test"; + const originalLog = console.log; + const originalError = console.error; + const lines = []; + console.log = (...args) => lines.push(args.join(" ")); + console.error = (...args) => lines.push(args.join(" ")); + try { + const result = await setupNim(null); + originalLog(JSON.stringify({ result, messages, lines })); + } finally { + console.log = originalLog; + console.error = originalError; + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + }, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.equal(payload.result.provider, "gemini-api"); + assert.equal(payload.result.preferredInferenceApi, "openai-responses"); + assert.ok(payload.lines.some((line) => line.includes("OpenAI endpoint validation failed"))); + assert.ok(payload.lines.some((line) => line.includes("Please choose a provider/model again"))); + assert.equal(payload.messages.filter((message) => /Choose \[/.test(message)).length, 2); + }); + + it("lets users re-enter an NVIDIA API key after authorization failure without restarting selection", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-build-auth-retry-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "build-auth-retry-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +body='{"error":{"message":"forbidden"}}' +status="403" +outfile="" +auth="" +url="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) outfile="$2"; shift 2 ;; + -H) + if echo "$2" | grep -q '^Authorization: Bearer '; then + auth="$2" + fi + shift 2 + ;; + *) url="$1"; shift ;; + esac +done +if echo "$auth" | grep -q 'nvapi-good' && echo "$url" | grep -q '/responses$'; then + body='{"id":"resp_123"}' + status="200" +elif echo "$auth" | grep -q 'nvapi-good' && echo "$url" | grep -q '/chat/completions$'; then + body='{"id":"chatcmpl-123"}' + status="200" +fi +printf '%s' "$body" > "$outfile" +printf '%s' "$status" +`, + { mode: 0o755 }, + ); + + const script = String.raw` +const credentials = require(${credentialsPath}); +const runner = require(${runnerPath}); + +const answers = ["", "", "retry", "nvapi-good"]; +const messages = []; + +credentials.prompt = async (message) => { + messages.push(message); + return answers.shift() || ""; +}; +runner.runCapture = () => ""; + +const { setupNim } = require(${onboardPath}); + +(async () => { + process.env.NVIDIA_API_KEY = "nvapi-bad"; + const originalLog = console.log; + const originalError = console.error; + const lines = []; + console.log = (...args) => lines.push(args.join(" ")); + console.error = (...args) => lines.push(args.join(" ")); + try { + const result = await setupNim(null); + originalLog(JSON.stringify({ result, messages, lines, key: process.env.NVIDIA_API_KEY })); + } finally { + console.log = originalLog; + console.error = originalError; + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + }, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.equal(payload.result.provider, "nvidia-prod"); + assert.equal(payload.result.preferredInferenceApi, "openai-responses"); + assert.equal(payload.key, "nvapi-good"); + assert.ok(payload.lines.some((line) => line.includes("NVIDIA Endpoints authorization failed"))); + assert.equal(payload.messages.filter((message) => /Choose \[/.test(message)).length, 1); + assert.equal( + payload.messages.filter((message) => /Choose model \[1\]/.test(message)).length, + 1, + ); + assert.ok( + payload.messages.some((message) => + /Type 'retry', 'back', or 'exit' \[retry\]: /.test(message), + ), + ); + assert.ok(payload.messages.some((message) => /NVIDIA Endpoints API key: /.test(message))); + }); + + it("lets users re-enter an OpenAI API key after authorization failure", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-openai-auth-retry-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "openai-auth-retry-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + writeOpenAiStyleAuthRetryCurl(fakeBin, "sk-good", ["gpt-5.4"]); + + const script = String.raw` +const credentials = require(${credentialsPath}); +const runner = require(${runnerPath}); + +const answers = ["2", "", "retry", "sk-good", ""]; +const messages = []; + +credentials.prompt = async (message) => { + messages.push(message); + return answers.shift() || ""; +}; +runner.runCapture = () => ""; + +const { setupNim } = require(${onboardPath}); + +(async () => { + process.env.OPENAI_API_KEY = "sk-bad"; + const originalLog = console.log; + const originalError = console.error; + const lines = []; + console.log = (...args) => lines.push(args.join(" ")); + console.error = (...args) => lines.push(args.join(" ")); + try { + const result = await setupNim(null); + originalLog(JSON.stringify({ result, messages, lines, key: process.env.OPENAI_API_KEY })); + } finally { + console.log = originalLog; + console.error = originalError; + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + }, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.equal(payload.result.provider, "openai-api"); + assert.equal(payload.result.model, "gpt-5.4"); + assert.equal(payload.result.preferredInferenceApi, "openai-responses"); + assert.equal(payload.key, "sk-good"); + assert.ok(payload.lines.some((line) => line.includes("OpenAI authorization failed"))); + assert.ok( + payload.messages.some((message) => + /Type 'retry', 'back', or 'exit' \[retry\]: /.test(message), + ), + ); + assert.ok(payload.messages.some((message) => /OpenAI API key: /.test(message))); + assert.equal(payload.messages.filter((message) => /Choose \[/.test(message)).length, 1); + assert.equal( + payload.messages.filter((message) => /Choose model \[1\]/.test(message)).length, + 2, + ); + }); + + it("lets users re-enter an Anthropic API key after authorization failure", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-anthropic-auth-retry-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "anthropic-auth-retry-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + writeAnthropicStyleAuthRetryCurl(fakeBin, "anthropic-good", ["claude-sonnet-4-6"]); + + const script = String.raw` +const credentials = require(${credentialsPath}); +const runner = require(${runnerPath}); + +const answers = ["4", "", "retry", "anthropic-good", ""]; +const messages = []; + +credentials.prompt = async (message) => { + messages.push(message); + return answers.shift() || ""; +}; +runner.runCapture = () => ""; + +const { setupNim } = require(${onboardPath}); + +(async () => { + process.env.ANTHROPIC_API_KEY = "anthropic-bad"; + const originalLog = console.log; + const originalError = console.error; + const lines = []; + console.log = (...args) => lines.push(args.join(" ")); + console.error = (...args) => lines.push(args.join(" ")); + try { + const result = await setupNim(null); + originalLog(JSON.stringify({ result, messages, lines, key: process.env.ANTHROPIC_API_KEY })); + } finally { + console.log = originalLog; + console.error = originalError; + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + }, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.equal(payload.result.provider, "anthropic-prod"); + assert.equal(payload.result.model, "claude-sonnet-4-6"); + assert.equal(payload.result.preferredInferenceApi, "anthropic-messages"); + assert.equal(payload.key, "anthropic-good"); + assert.ok(payload.lines.some((line) => line.includes("Anthropic authorization failed"))); + assert.ok( + payload.messages.some((message) => + /Type 'retry', 'back', or 'exit' \[retry\]: /.test(message), + ), + ); + assert.ok(payload.messages.some((message) => /Anthropic API key: /.test(message))); + assert.equal(payload.messages.filter((message) => /Choose \[/.test(message)).length, 1); + assert.equal( + payload.messages.filter((message) => /Choose model \[1\]/.test(message)).length, + 2, + ); + }); + + it("lets users re-enter a Gemini API key after authorization failure", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-gemini-auth-retry-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "gemini-auth-retry-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + writeOpenAiStyleAuthRetryCurl(fakeBin, "gemini-good", ["gemini-2.5-flash"]); + + const script = String.raw` +const credentials = require(${credentialsPath}); +const runner = require(${runnerPath}); + +const answers = ["6", "", "retry", "gemini-good", ""]; +const messages = []; + +credentials.prompt = async (message) => { + messages.push(message); + return answers.shift() || ""; +}; +runner.runCapture = () => ""; + +const { setupNim } = require(${onboardPath}); + +(async () => { + process.env.GEMINI_API_KEY = "gemini-bad"; + const originalLog = console.log; + const originalError = console.error; + const lines = []; + console.log = (...args) => lines.push(args.join(" ")); + console.error = (...args) => lines.push(args.join(" ")); + try { + const result = await setupNim(null); + originalLog(JSON.stringify({ result, messages, lines, key: process.env.GEMINI_API_KEY })); + } finally { + console.log = originalLog; + console.error = originalError; + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + }, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.equal(payload.result.provider, "gemini-api"); + assert.equal(payload.result.model, "gemini-2.5-flash"); + assert.equal(payload.result.preferredInferenceApi, "openai-responses"); + assert.equal(payload.key, "gemini-good"); + assert.ok(payload.lines.some((line) => line.includes("Google Gemini authorization failed"))); + assert.ok( + payload.messages.some((message) => + /Type 'retry', 'back', or 'exit' \[retry\]: /.test(message), + ), + ); + assert.ok(payload.messages.some((message) => /Google Gemini API key: /.test(message))); + assert.equal(payload.messages.filter((message) => /Choose \[/.test(message)).length, 1); + assert.equal( + payload.messages.filter((message) => /Choose model \[5\]/.test(message)).length, + 2, + ); + }); + + it("lets users re-enter a custom OpenAI-compatible API key without re-entering the endpoint URL", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-onboard-custom-openai-auth-retry-"), + ); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "custom-openai-auth-retry-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + writeOpenAiStyleAuthRetryCurl(fakeBin, "proxy-good", ["custom-model"]); + + const script = String.raw` +const credentials = require(${credentialsPath}); +const runner = require(${runnerPath}); + +const answers = ["3", "https://proxy.example.com/v1/chat/completions?token=secret#frag", "custom-model", "retry", "proxy-good", "custom-model"]; +const messages = []; + +credentials.prompt = async (message) => { + messages.push(message); + return answers.shift() || ""; +}; +runner.runCapture = () => ""; + +const { setupNim } = require(${onboardPath}); + +(async () => { + process.env.COMPATIBLE_API_KEY = "proxy-bad"; + const originalLog = console.log; + const originalError = console.error; + const lines = []; + console.log = (...args) => lines.push(args.join(" ")); + console.error = (...args) => lines.push(args.join(" ")); + try { + const result = await setupNim(null); + originalLog(JSON.stringify({ result, messages, lines, key: process.env.COMPATIBLE_API_KEY })); + } finally { + console.log = originalLog; + console.error = originalError; + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + }, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.equal(payload.result.provider, "compatible-endpoint"); + assert.equal(payload.result.model, "custom-model"); + assert.equal(payload.result.endpointUrl, "https://proxy.example.com/v1"); + assert.equal(payload.result.preferredInferenceApi, "openai-responses"); + assert.equal(payload.key, "proxy-good"); + assert.ok( + payload.lines.some((line) => + line.includes("Other OpenAI-compatible endpoint authorization failed"), + ), + ); + assert.ok( + payload.messages.some((message) => + /Type 'retry', 'back', or 'exit' \[retry\]: /.test(message), + ), + ); + assert.ok( + payload.messages.some((message) => + /Other OpenAI-compatible endpoint API key: /.test(message), + ), + ); + assert.equal( + payload.messages.filter((message) => /OpenAI-compatible base URL/.test(message)).length, + 1, + ); + assert.equal( + payload.messages.filter((message) => /Other OpenAI-compatible endpoint model/.test(message)) + .length, + 2, + ); + assert.equal(payload.messages.filter((message) => /Choose \[/.test(message)).length, 1); + }); + + it("lets users re-enter a custom Anthropic-compatible API key without re-entering the endpoint URL", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-onboard-custom-anthropic-auth-retry-"), + ); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "custom-anthropic-auth-retry-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + writeAnthropicStyleAuthRetryCurl(fakeBin, "anthropic-proxy-good", ["claude-proxy"]); + + const script = String.raw` +const credentials = require(${credentialsPath}); +const runner = require(${runnerPath}); + +const answers = ["5", "https://proxy.example.com/v1/messages?token=secret#frag", "claude-proxy", "retry", "anthropic-proxy-good", "claude-proxy"]; +const messages = []; + +credentials.prompt = async (message) => { + messages.push(message); + return answers.shift() || ""; +}; +runner.runCapture = () => ""; + +const { setupNim } = require(${onboardPath}); + +(async () => { + process.env.COMPATIBLE_ANTHROPIC_API_KEY = "anthropic-proxy-bad"; + const originalLog = console.log; + const originalError = console.error; + const lines = []; + console.log = (...args) => lines.push(args.join(" ")); + console.error = (...args) => lines.push(args.join(" ")); + try { + const result = await setupNim(null); + originalLog(JSON.stringify({ result, messages, lines, key: process.env.COMPATIBLE_ANTHROPIC_API_KEY })); + } finally { + console.log = originalLog; + console.error = originalError; + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + }, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.equal(payload.result.provider, "compatible-anthropic-endpoint"); + assert.equal(payload.result.model, "claude-proxy"); + assert.equal(payload.result.endpointUrl, "https://proxy.example.com"); + assert.equal(payload.result.preferredInferenceApi, "anthropic-messages"); + assert.equal(payload.key, "anthropic-proxy-good"); + assert.ok( + payload.lines.some((line) => + line.includes("Other Anthropic-compatible endpoint authorization failed"), + ), + ); + assert.ok( + payload.messages.some((message) => + /Type 'retry', 'back', or 'exit' \[retry\]: /.test(message), + ), + ); + assert.ok( + payload.messages.some((message) => + /Other Anthropic-compatible endpoint API key: /.test(message), + ), + ); + assert.equal( + payload.messages.filter((message) => /Anthropic-compatible base URL/.test(message)).length, + 1, + ); + assert.equal( + payload.messages.filter((message) => + /Other Anthropic-compatible endpoint model/.test(message), + ).length, + 2, + ); + assert.equal(payload.messages.filter((message) => /Choose \[/.test(message)).length, 1); + }); + + it("forces openai-completions for vLLM even when probe detects openai-responses", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-vllm-override-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "vllm-override-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + // Fake curl: /v1/responses returns 200 (so probe detects openai-responses), + // /v1/models returns a vLLM model list + fs.writeFileSync( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +body='' +status="200" +outfile="" +url="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) outfile="$2"; shift 2 ;; + *) url="$1"; shift ;; + esac +done +if echo "$url" | grep -q '/v1/models'; then + body='{"data":[{"id":"meta-llama/Llama-3.3-70B-Instruct"}]}' +elif echo "$url" | grep -q '/v1/responses'; then + body='{"id":"resp_123","output":[{"type":"message","content":[{"type":"output_text","text":"ok"}]}]}' +elif echo "$url" | grep -q '/v1/chat/completions'; then + body='{"id":"chatcmpl-123","choices":[{"message":{"content":"ok"}}]}' +fi +printf '%s' "$body" > "$outfile" +printf '%s' "$status" +`, + { mode: 0o755 }, + ); + + // vLLM is option 7 (build, openai, custom, anthropic, anthropicCompatible, gemini, vllm) + const script = String.raw` +const credentials = require(${credentialsPath}); +const runner = require(${runnerPath}); + +const answers = ["7"]; +const messages = []; + +credentials.prompt = async (message) => { + messages.push(message); + return answers.shift() || ""; +}; +credentials.ensureApiKey = async () => {}; +runner.runCapture = (command) => { + if (command.includes("command -v ollama")) return ""; + if (command.includes("localhost:11434")) return ""; + if (command.includes("localhost:8000/v1/models")) return JSON.stringify({ data: [{ id: "meta-llama/Llama-3.3-70B-Instruct" }] }); + return ""; +}; + +const { setupNim } = require(${onboardPath}); + +(async () => { + const originalLog = console.log; + const lines = []; + console.log = (...args) => lines.push(args.join(" ")); + try { + const result = await setupNim(null); + originalLog(JSON.stringify({ result, messages, lines })); + } finally { + console.log = originalLog; + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + NEMOCLAW_EXPERIMENTAL: "1", + }, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.equal(payload.result.provider, "vllm-local"); + assert.equal(payload.result.model, "meta-llama/Llama-3.3-70B-Instruct"); + // Key assertion: even though probe detected openai-responses, the override + // forces openai-completions so tool-call-parser works correctly. + assert.equal(payload.result.preferredInferenceApi, "openai-completions"); + assert.ok(payload.lines.some((line) => line.includes("Using existing vLLM"))); + assert.ok(payload.lines.some((line) => line.includes("tool-call-parser requires"))); + }); + + it("forces openai-completions for NIM-local even when probe detects openai-responses", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-nim-override-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "nim-override-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + const nimPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "nim.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + // Fake curl: /v1/responses returns 200 (probe detects openai-responses) + fs.writeFileSync( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +body='' +status="200" +outfile="" +url="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) outfile="$2"; shift 2 ;; + *) url="$1"; shift ;; + esac +done +if echo "$url" | grep -q '/v1/models'; then + body='{"data":[{"id":"nvidia/nemotron-3-nano"}]}' +elif echo "$url" | grep -q '/v1/responses'; then + body='{"id":"resp_123","output":[{"type":"message","content":[{"type":"output_text","text":"ok"}]}]}' +elif echo "$url" | grep -q '/v1/chat/completions'; then + body='{"id":"chatcmpl-123","choices":[{"message":{"content":"ok"}}]}' +fi +printf '%s' "$body" > "$outfile" +printf '%s' "$status" +`, + { mode: 0o755 }, + ); + + // NIM-local is option 7 (build, openai, custom, anthropic, anthropicCompatible, gemini, nim-local) + // No ollama, no vLLM — only NIM-local shows up as experimental option + const script = String.raw` +const credentials = require(${credentialsPath}); +const runner = require(${runnerPath}); + +// Mock nim module before onboard.js requires it +const nimMod = require(${nimPath}); +nimMod.listModels = () => [{ name: "nvidia/nemotron-3-nano", image: "fake", minGpuMemoryMB: 8000 }]; +nimMod.pullNimImage = () => {}; +nimMod.containerName = () => "nemoclaw-nim-test"; +nimMod.startNimContainerByName = () => "container-123"; +nimMod.waitForNimHealth = () => true; + +// Select option 7 (nim-local), then model 1 +const answers = ["7", "1"]; +const messages = []; + +credentials.prompt = async (message) => { + messages.push(message); + return answers.shift() || ""; +}; +credentials.ensureApiKey = async () => {}; +runner.runCapture = (command) => { + if (command.includes("command -v ollama")) return ""; + if (command.includes("localhost:11434")) return ""; + if (command.includes("localhost:8000/v1/models")) return ""; + return ""; +}; + +const { setupNim } = require(${onboardPath}); + +(async () => { + const originalLog = console.log; + const lines = []; + console.log = (...args) => lines.push(args.join(" ")); + try { + // Pass a GPU object with nimCapable: true + const result = await setupNim({ type: "nvidia", totalMemoryMB: 16000, nimCapable: true }); + originalLog(JSON.stringify({ result, messages, lines })); + } finally { + console.log = originalLog; + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + NEMOCLAW_EXPERIMENTAL: "1", + }, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.equal(payload.result.provider, "vllm-local"); + assert.equal(payload.result.model, "nvidia/nemotron-3-nano"); + // Key assertion: NIM uses vLLM internally — same override must apply. + assert.equal(payload.result.preferredInferenceApi, "openai-completions"); + assert.ok(payload.lines.some((line) => line.includes("tool-call-parser requires"))); }); }); diff --git a/test/onboard.test.js b/test/onboard.test.js index f1240a9ed..010a5ec45 100644 --- a/test/onboard.test.js +++ b/test/onboard.test.js @@ -10,15 +10,67 @@ import { describe, expect, it } from "vitest"; import { buildSandboxConfigSyncScript, + classifySandboxCreateFailure, + getGatewayReuseState, + getPortConflictServiceHints, getFutureShellPathHint, - getInstalledOpenshellVersion, getSandboxInferenceConfig, + getInstalledOpenshellVersion, + getRequestedModelHint, + getRequestedProviderHint, + getRequestedSandboxNameHint, + getResumeConfigConflicts, + getResumeSandboxConflict, + getSandboxStateFromOutputs, getStableGatewayImageRef, + isGatewayHealthy, + classifyValidationFailure, + isLoopbackHostname, + normalizeProviderBaseUrl, patchStagedDockerfile, + printSandboxCreateRecoveryHints, + resolveDashboardForwardTarget, + shouldIncludeBuildContextPath, writeSandboxConfigSyncFile, } from "../bin/lib/onboard"; describe("onboard helpers", () => { + it("classifies sandbox create timeout failures and tracks upload progress", () => { + expect( + classifySandboxCreateFailure("Error: failed to read image export stream\nTimeout error").kind, + ).toBe("image_transfer_timeout"); + expect( + classifySandboxCreateFailure( + [ + ' Pushing image openshell/sandbox-from:123 into gateway "nemoclaw"', + " [progress] Uploaded to gateway", + "Error: failed to read image export stream", + ].join("\n"), + ), + ).toEqual({ + kind: "image_transfer_timeout", + uploadedToGateway: true, + }); + }); + + it("classifies sandbox create connection resets and incomplete create streams", () => { + expect(classifySandboxCreateFailure("Connection reset by peer").kind).toBe( + "image_transfer_reset", + ); + expect( + classifySandboxCreateFailure( + [ + " Image openshell/sandbox-from:123 is available in the gateway.", + "Created sandbox: my-assistant", + "Error: stream closed unexpectedly", + ].join("\n"), + ), + ).toEqual({ + kind: "sandbox_create_incomplete", + uploadedToGateway: true, + }); + }); + it("builds a sandbox sync script that only writes nemoclaw config", () => { const script = buildSandboxConfigSyncScript({ endpointType: "custom", @@ -50,11 +102,17 @@ describe("onboard helpers", () => { "ARG CHAT_UI_URL=http://127.0.0.1:18789", "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", "ARG NEMOCLAW_BUILD_ID=default", - ].join("\n") + ].join("\n"), ); try { - patchStagedDockerfile(dockerfilePath, "gpt-5.4", "http://127.0.0.1:19999", "build-123", "openai-api"); + patchStagedDockerfile( + dockerfilePath, + "gpt-5.4", + "http://127.0.0.1:19999", + "build-123", + "openai-api", + ); const patched = fs.readFileSync(dockerfilePath, "utf8"); assert.match(patched, /^ARG NEMOCLAW_MODEL=gpt-5\.4$/m); assert.match(patched, /^ARG NEMOCLAW_PROVIDER_KEY=openai$/m); @@ -75,7 +133,53 @@ describe("onboard helpers", () => { inferenceBaseUrl: "https://inference.local/v1", inferenceApi: "openai-completions", inferenceCompat: null, - } + }, + ); + }); + + it("classifies model-related 404/405 responses as model retries before endpoint retries", () => { + expect( + classifyValidationFailure({ + httpStatus: 404, + message: "HTTP 404: model not found", + }), + ).toEqual({ kind: "model", retry: "model" }); + expect( + classifyValidationFailure({ + httpStatus: 405, + message: "HTTP 405: unsupported model", + }), + ).toEqual({ kind: "model", retry: "model" }); + }); + + it("normalizes anthropic-compatible base URLs with a trailing /v1", () => { + expect(normalizeProviderBaseUrl("https://proxy.example.com/v1", "anthropic")).toBe( + "https://proxy.example.com", + ); + expect(normalizeProviderBaseUrl("https://proxy.example.com/v1/messages", "anthropic")).toBe( + "https://proxy.example.com", + ); + }); + + it("detects loopback dashboard hosts and resolves remote binds correctly", () => { + expect(isLoopbackHostname("localhost")).toBe(true); + expect(isLoopbackHostname("127.0.0.1")).toBe(true); + expect(isLoopbackHostname("127.0.0.42")).toBe(true); + expect(isLoopbackHostname("[::1]")).toBe(true); + expect(isLoopbackHostname("chat.example.com")).toBe(false); + + expect(resolveDashboardForwardTarget("http://127.0.0.1:18789")).toBe("18789"); + expect(resolveDashboardForwardTarget("http://127.0.0.42:18789")).toBe("18789"); + expect(resolveDashboardForwardTarget("http://[::1]:18789")).toBe("18789"); + expect(resolveDashboardForwardTarget("https://chat.example.com")).toBe("0.0.0.0:18789"); + expect(resolveDashboardForwardTarget("http://10.0.0.25:18789")).toBe("0.0.0.0:18789"); + }); + + it("prints platform-appropriate service hints for port conflicts", () => { + expect(getPortConflictServiceHints("darwin").join("\n")).toMatch(/launchctl unload/); + expect(getPortConflictServiceHints("darwin").join("\n")).not.toMatch(/systemctl --user/); + expect(getPortConflictServiceHints("linux").join("\n")).toMatch( + /systemctl --user stop openclaw-gateway.service/, ); }); @@ -93,7 +197,7 @@ describe("onboard helpers", () => { "ARG NEMOCLAW_INFERENCE_API=openai-completions", "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", "ARG NEMOCLAW_BUILD_ID=default", - ].join("\n") + ].join("\n"), ); try { @@ -102,7 +206,7 @@ describe("onboard helpers", () => { "claude-sonnet-4-5", "http://127.0.0.1:18789", "build-claude", - "anthropic-prod" + "anthropic-prod", ); const patched = fs.readFileSync(dockerfilePath, "utf8"); assert.match(patched, /^ARG NEMOCLAW_MODEL=claude-sonnet-4-5$/m); @@ -116,85 +220,714 @@ describe("onboard helpers", () => { }); it("maps Gemini to the routed inference provider with supportsStore disabled", () => { - assert.deepEqual( - getSandboxInferenceConfig("gemini-2.5-flash", "gemini-api"), - { - providerKey: "inference", - primaryModelRef: "inference/gemini-2.5-flash", - inferenceBaseUrl: "https://inference.local/v1", - inferenceApi: "openai-completions", - inferenceCompat: { - supportsStore: false, - }, - } - ); + assert.deepEqual(getSandboxInferenceConfig("gemini-2.5-flash", "gemini-api"), { + providerKey: "inference", + primaryModelRef: "inference/gemini-2.5-flash", + inferenceBaseUrl: "https://inference.local/v1", + inferenceApi: "openai-completions", + inferenceCompat: { + supportsStore: false, + }, + }); }); it("uses a probed Responses API override when one is available", () => { - assert.deepEqual( - getSandboxInferenceConfig("gpt-5.4", "openai-api", "openai-responses"), - { - providerKey: "openai", - primaryModelRef: "openai/gpt-5.4", - inferenceBaseUrl: "https://inference.local/v1", - inferenceApi: "openai-responses", - inferenceCompat: null, - } - ); + assert.deepEqual(getSandboxInferenceConfig("gpt-5.4", "openai-api", "openai-responses"), { + providerKey: "openai", + primaryModelRef: "openai/gpt-5.4", + inferenceBaseUrl: "https://inference.local/v1", + inferenceApi: "openai-responses", + inferenceCompat: null, + }); }); it("pins the gateway image to the installed OpenShell release version", () => { expect(getInstalledOpenshellVersion("openshell 0.0.12")).toBe("0.0.12"); expect(getInstalledOpenshellVersion("openshell 0.0.13-dev.8+gbbcaed2ea")).toBe("0.0.13"); expect(getInstalledOpenshellVersion("bogus")).toBe(null); - expect(getStableGatewayImageRef("openshell 0.0.12")).toBe("ghcr.io/nvidia/openshell/cluster:0.0.12"); - expect(getStableGatewayImageRef("openshell 0.0.13-dev.8+gbbcaed2ea")).toBe("ghcr.io/nvidia/openshell/cluster:0.0.13"); + expect(getStableGatewayImageRef("openshell 0.0.12")).toBe( + "ghcr.io/nvidia/openshell/cluster:0.0.12", + ); + expect(getStableGatewayImageRef("openshell 0.0.13-dev.8+gbbcaed2ea")).toBe( + "ghcr.io/nvidia/openshell/cluster:0.0.13", + ); expect(getStableGatewayImageRef("bogus")).toBe(null); }); + it("treats the gateway as healthy only when nemoclaw is running and connected", () => { + expect( + isGatewayHealthy( + "Gateway status: Connected\nGateway: nemoclaw", + "Gateway Info\n\n Gateway: nemoclaw\n Gateway endpoint: https://127.0.0.1:8080", + "Gateway Info\n\n Gateway: nemoclaw\n Gateway endpoint: https://127.0.0.1:8080", + ), + ).toBe(true); + expect( + isGatewayHealthy( + "\u001b[1mServer Status\u001b[0m\n\n Gateway: openshell\n Server: https://127.0.0.1:8080\n Status: Connected", + "Error: × No gateway metadata found for 'nemoclaw'.", + "Gateway Info\n\n Gateway: openshell\n Gateway endpoint: https://127.0.0.1:8080", + ), + ).toBe(false); + expect( + isGatewayHealthy( + "Server Status\n\n Gateway: openshell\n Status: Connected", + "Gateway Info\n\n Gateway: nemoclaw\n Gateway endpoint: https://127.0.0.1:8080", + "Gateway Info\n\n Gateway: openshell\n Gateway endpoint: https://127.0.0.1:8080", + ), + ).toBe(false); + expect(isGatewayHealthy("Gateway status: Disconnected", "Gateway: nemoclaw")).toBe(false); + expect(isGatewayHealthy("Gateway status: Connected", "Gateway: something-else")).toBe(false); + }); + + it("classifies gateway reuse states conservatively", () => { + expect( + getGatewayReuseState( + "Gateway status: Connected\nGateway: nemoclaw", + "Gateway Info\n\n Gateway: nemoclaw\n Gateway endpoint: https://127.0.0.1:8080", + "Gateway Info\n\n Gateway: nemoclaw\n Gateway endpoint: https://127.0.0.1:8080", + ), + ).toBe("healthy"); + expect( + getGatewayReuseState( + "Gateway status: Connected", + "Error: × No gateway metadata found for 'nemoclaw'.", + "Gateway Info\n\n Gateway: openshell\n Gateway endpoint: https://127.0.0.1:8080", + ), + ).toBe("foreign-active"); + expect( + getGatewayReuseState( + "Server Status\n\n Gateway: openshell\n Status: Connected", + "Gateway Info\n\n Gateway: nemoclaw\n Gateway endpoint: https://127.0.0.1:8080", + "Gateway Info\n\n Gateway: openshell\n Gateway endpoint: https://127.0.0.1:8080", + ), + ).toBe("foreign-active"); + expect( + getGatewayReuseState( + "Gateway status: Disconnected", + "Gateway Info\n\n Gateway: nemoclaw\n Gateway endpoint: https://127.0.0.1:8080", + ), + ).toBe("stale"); + expect( + getGatewayReuseState( + "Gateway status: Connected\nGateway: nemoclaw", + "", + "Gateway Info\n\n Gateway: nemoclaw\n Gateway endpoint: https://127.0.0.1:8080", + ), + ).toBe("active-unnamed"); + expect( + getGatewayReuseState( + "Gateway status: Connected", + "", + "Gateway Info\n\n Gateway: openshell\n Gateway endpoint: https://127.0.0.1:8080", + ), + ).toBe("foreign-active"); + expect(getGatewayReuseState("", "")).toBe("missing"); + }); + + it("classifies sandbox reuse states from openshell outputs", () => { + expect( + getSandboxStateFromOutputs( + "my-assistant", + "Name: my-assistant", + "my-assistant Ready 2m ago", + ), + ).toBe("ready"); + expect( + getSandboxStateFromOutputs( + "my-assistant", + "Name: my-assistant", + "my-assistant NotReady init failed", + ), + ).toBe("not_ready"); + expect(getSandboxStateFromOutputs("my-assistant", "", "")).toBe("missing"); + }); + + it("filters local-only artifacts out of the sandbox build context", () => { + expect( + shouldIncludeBuildContextPath( + "/repo/nemoclaw-blueprint", + "/repo/nemoclaw-blueprint/orchestrator/main.py", + ), + ).toBe(true); + expect( + shouldIncludeBuildContextPath( + "/repo/nemoclaw-blueprint", + "/repo/nemoclaw-blueprint/.venv/bin/python", + ), + ).toBe(false); + expect( + shouldIncludeBuildContextPath( + "/repo/nemoclaw-blueprint", + "/repo/nemoclaw-blueprint/.ruff_cache/cache", + ), + ).toBe(false); + expect( + shouldIncludeBuildContextPath( + "/repo/nemoclaw-blueprint", + "/repo/nemoclaw-blueprint/._pyvenv.cfg", + ), + ).toBe(false); + }); + + it("normalizes sandbox name hints from the environment", () => { + const previous = process.env.NEMOCLAW_SANDBOX_NAME; + process.env.NEMOCLAW_SANDBOX_NAME = " My-Assistant "; + try { + expect(getRequestedSandboxNameHint()).toBe("my-assistant"); + } finally { + if (previous === undefined) { + delete process.env.NEMOCLAW_SANDBOX_NAME; + } else { + process.env.NEMOCLAW_SANDBOX_NAME = previous; + } + } + }); + + it("detects resume conflicts when a different sandbox is requested", () => { + const previous = process.env.NEMOCLAW_SANDBOX_NAME; + process.env.NEMOCLAW_SANDBOX_NAME = "other-sandbox"; + try { + expect(getResumeSandboxConflict({ sandboxName: "my-assistant" })).toEqual({ + requestedSandboxName: "other-sandbox", + recordedSandboxName: "my-assistant", + }); + expect(getResumeSandboxConflict({ sandboxName: "other-sandbox" })).toBe(null); + } finally { + if (previous === undefined) { + delete process.env.NEMOCLAW_SANDBOX_NAME; + } else { + process.env.NEMOCLAW_SANDBOX_NAME = previous; + } + } + }); + + it("returns provider and model hints only for non-interactive runs", () => { + const previousProvider = process.env.NEMOCLAW_PROVIDER; + const previousModel = process.env.NEMOCLAW_MODEL; + process.env.NEMOCLAW_PROVIDER = "cloud"; + process.env.NEMOCLAW_MODEL = "nvidia/test-model"; + try { + expect(getRequestedProviderHint(true)).toBe("build"); + expect(getRequestedModelHint(true)).toBe("nvidia/test-model"); + expect(getRequestedProviderHint(false)).toBe(null); + expect(getRequestedModelHint(false)).toBe(null); + } finally { + if (previousProvider === undefined) { + delete process.env.NEMOCLAW_PROVIDER; + } else { + process.env.NEMOCLAW_PROVIDER = previousProvider; + } + if (previousModel === undefined) { + delete process.env.NEMOCLAW_MODEL; + } else { + process.env.NEMOCLAW_MODEL = previousModel; + } + } + }); + + it("detects resume conflicts for explicit provider and model changes", () => { + const previousProvider = process.env.NEMOCLAW_PROVIDER; + const previousModel = process.env.NEMOCLAW_MODEL; + process.env.NEMOCLAW_PROVIDER = "cloud"; + process.env.NEMOCLAW_MODEL = "nvidia/other-model"; + try { + // Provider conflict uses a two-stage alias chain in non-interactive mode: + // "cloud" first resolves to the requested hint, then that hint resolves + // to the effective provider name "nvidia-prod" for conflict comparison. + expect( + getResumeConfigConflicts( + { + sandboxName: "my-assistant", + provider: "nvidia-nim", + model: "nvidia/nemotron-3-super-120b-a12b", + }, + { nonInteractive: true }, + ), + ).toEqual([ + { + field: "provider", + requested: "nvidia-prod", + recorded: "nvidia-nim", + }, + { + field: "model", + requested: "nvidia/other-model", + recorded: "nvidia/nemotron-3-super-120b-a12b", + }, + ]); + } finally { + if (previousProvider === undefined) { + delete process.env.NEMOCLAW_PROVIDER; + } else { + process.env.NEMOCLAW_PROVIDER = previousProvider; + } + if (previousModel === undefined) { + delete process.env.NEMOCLAW_MODEL; + } else { + process.env.NEMOCLAW_MODEL = previousModel; + } + } + }); + it("returns a future-shell PATH hint for user-local openshell installs", () => { expect(getFutureShellPathHint("/home/test/.local/bin", "/usr/local/bin:/usr/bin")).toBe( - 'export PATH="/home/test/.local/bin:$PATH"' + 'export PATH="/home/test/.local/bin:$PATH"', ); }); it("skips the future-shell PATH hint when the bin dir is already on PATH", () => { expect( - getFutureShellPathHint("/home/test/.local/bin", "/home/test/.local/bin:/usr/local/bin:/usr/bin") + getFutureShellPathHint( + "/home/test/.local/bin", + "/home/test/.local/bin:/usr/local/bin:/usr/bin", + ), ).toBe(null); }); it("writes sandbox sync scripts to a temp file for stdin redirection", () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-test-")); + const scriptFile = writeSandboxConfigSyncFile("echo test"); try { - const scriptFile = writeSandboxConfigSyncFile("echo test", tmpDir, 1234); - expect(scriptFile).toBe(path.join(tmpDir, "nemoclaw-sync-1234.sh")); + expect(scriptFile).toMatch(/nemoclaw-sync.*\.sh$/); expect(fs.readFileSync(scriptFile, "utf8")).toBe("echo test\n"); + // Verify the file lives inside a mkdtemp-created directory (not directly in /tmp) + const parentDir = path.dirname(scriptFile); + expect(parentDir).not.toBe(os.tmpdir()); + expect(parentDir).toContain("nemoclaw-sync"); + if (process.platform !== "win32") { + const stat = fs.statSync(scriptFile); + expect(stat.mode & 0o777).toBe(0o600); + } + } finally { + // mirrors cleanupTempDir() — inline guard to safely remove mkdtemp directory + const parentDir = path.dirname(scriptFile); + if (parentDir !== os.tmpdir() && path.basename(parentDir).startsWith("nemoclaw-sync-")) { + fs.rmSync(parentDir, { recursive: true, force: true }); + } + } + }); + + it("passes credential names to openshell without embedding secret values in argv", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-inference-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "setup-inference-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + const registryPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "registry.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); + + const script = String.raw` +const runner = require(${runnerPath}); +const registry = require(${registryPath}); + +const commands = []; +runner.run = (command, opts = {}) => { + commands.push({ command, env: opts.env || null }); + return { status: 0 }; +}; +runner.runCapture = (command) => { + if (command.includes("inference") && command.includes("get")) { + return [ + "Gateway inference:", + "", + " Route: inference.local", + " Provider: nvidia-nim", + " Model: nvidia/nemotron-3-super-120b-a12b", + " Version: 1", + ].join("\\n"); + } + return ""; +}; +registry.updateSandbox = () => true; + +process.env.NVIDIA_API_KEY = "nvapi-secret-value"; + +const { setupInference } = require(${onboardPath}); + +(async () => { + await setupInference("test-box", "nvidia/nemotron-3-super-120b-a12b", "nvidia-nim"); + console.log(JSON.stringify(commands)); +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + }, + }); + + expect(result.status).toBe(0); + const commands = JSON.parse(result.stdout.trim().split("\n").pop()); + assert.equal(commands.length, 3); + assert.match(commands[0].command, /gateway' 'select' 'nemoclaw'/); + assert.match(commands[1].command, /'--credential' 'NVIDIA_API_KEY'/); + assert.doesNotMatch(commands[1].command, /nvapi-secret-value/); + assert.match(commands[1].command, /provider' 'create'/); + assert.match(commands[2].command, /inference' 'set'/); + }); + + it("detects when the live inference route already matches the requested provider and model", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-inference-ready-")); + const fakeOpenshell = path.join(tmpDir, "openshell"); + const scriptPath = path.join(tmpDir, "inference-ready-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + + fs.writeFileSync( + fakeOpenshell, + `#!/usr/bin/env bash +if [ "$1" = "inference" ] && [ "$2" = "get" ]; then + cat <<'EOF' +Gateway inference: + + Route: inference.local + Provider: nvidia-prod + Model: nvidia/nemotron-3-super-120b-a12b + Version: 1 +EOF + exit 0 +fi +exit 1 +`, + { mode: 0o755 }, + ); + + fs.writeFileSync( + scriptPath, + ` +const { isInferenceRouteReady } = require(${onboardPath}); +console.log(JSON.stringify({ + same: isInferenceRouteReady("nvidia-prod", "nvidia/nemotron-3-super-120b-a12b"), + otherModel: isInferenceRouteReady("nvidia-prod", "nvidia/other-model"), + otherProvider: isInferenceRouteReady("openai-api", "nvidia/nemotron-3-super-120b-a12b"), +})); +`, + ); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + PATH: `${tmpDir}:${process.env.PATH || ""}`, + }, + }); + + try { + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout.trim())).toEqual({ + same: true, + otherModel: false, + otherProvider: false, + }); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("detects when OpenClaw is already configured inside the sandbox", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-ready-")); + const fakeOpenshell = path.join(tmpDir, "openshell"); + const scriptPath = path.join(tmpDir, "openclaw-ready-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + + fs.writeFileSync( + fakeOpenshell, + `#!/usr/bin/env bash +if [ "$1" = "sandbox" ] && [ "$2" = "download" ]; then + dest="\${@: -1}" + mkdir -p "$dest/sandbox/.openclaw" + cat > "$dest/sandbox/.openclaw/openclaw.json" <<'EOF' +{"gateway":{"auth":{"token":"test-token"}}} +EOF + exit 0 +fi +exit 1 +`, + { mode: 0o755 }, + ); + + fs.writeFileSync( + scriptPath, + ` +const { isOpenclawReady } = require(${onboardPath}); +console.log(JSON.stringify({ + ready: isOpenclawReady("my-assistant"), +})); +`, + ); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + PATH: `${tmpDir}:${process.env.PATH || ""}`, + }, + }); + + try { + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout.trim())).toEqual({ ready: true }); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); - it("passes credential names to openshell without embedding secret values in argv", () => { + it("detects when recorded policy presets are already applied", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-ready-")); + const registryDir = path.join(tmpDir, ".nemoclaw"); + const registryFile = path.join(registryDir, "sandboxes.json"); + const scriptPath = path.join(tmpDir, "policy-ready-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + registryFile, + JSON.stringify( + { + sandboxes: { + "my-assistant": { + name: "my-assistant", + policies: ["pypi", "npm"], + }, + }, + defaultSandbox: "my-assistant", + }, + null, + 2, + ), + ); + + fs.writeFileSync( + scriptPath, + ` +const { arePolicyPresetsApplied } = require(${onboardPath}); +console.log(JSON.stringify({ + ready: arePolicyPresetsApplied("my-assistant", ["pypi", "npm"]), + missing: arePolicyPresetsApplied("my-assistant", ["pypi", "slack"]), + empty: arePolicyPresetsApplied("my-assistant", []), +})); +`, + ); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + }, + }); + + try { + expect(result.status).toBe(0); + const payload = JSON.parse(result.stdout.trim()); + expect(payload).toEqual({ + ready: true, + missing: false, + empty: false, + }); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("uses native Anthropic provider creation without embedding the secret in argv", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-anthropic-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "setup-anthropic-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + const registryPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "registry.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); + + const script = String.raw` +const runner = require(${runnerPath}); +const registry = require(${registryPath}); + +const commands = []; +runner.run = (command, opts = {}) => { + commands.push({ command, env: opts.env || null }); + return { status: 0 }; +}; +runner.runCapture = (command) => { + if (command.includes("inference") && command.includes("get")) { + return [ + "Gateway inference:", + "", + " Route: inference.local", + " Provider: anthropic-prod", + " Model: claude-sonnet-4-5", + " Version: 1", + ].join("\n"); + } + return ""; +}; +registry.updateSandbox = () => true; + +process.env.ANTHROPIC_API_KEY = "sk-ant-secret-value"; + +const { setupInference } = require(${onboardPath}); + +(async () => { + await setupInference("test-box", "claude-sonnet-4-5", "anthropic-prod", "https://api.anthropic.com", "ANTHROPIC_API_KEY"); + console.log(JSON.stringify(commands)); +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + }, + }); + + assert.equal(result.status, 0, result.stderr); + const commands = JSON.parse(result.stdout.trim().split("\n").pop()); + assert.equal(commands.length, 3); + assert.match(commands[0].command, /gateway' 'select' 'nemoclaw'/); + assert.match(commands[1].command, /'--type' 'anthropic'/); + assert.match(commands[1].command, /'--credential' 'ANTHROPIC_API_KEY'/); + assert.doesNotMatch(commands[1].command, /sk-ant-secret-value/); + assert.match(commands[2].command, /'--provider' 'anthropic-prod'/); + }); + + it("updates OpenAI-compatible providers without passing an unsupported --type flag", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-openai-update-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "setup-openai-update-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + const registryPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "registry.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); + + const script = String.raw` +const runner = require(${runnerPath}); +const registry = require(${registryPath}); + +const commands = []; +let callIndex = 0; +runner.run = (command, opts = {}) => { + commands.push({ command, env: opts.env || null }); + callIndex += 1; + return { status: callIndex === 2 ? 1 : 0 }; +}; +runner.runCapture = (command) => { + if (command.includes("inference") && command.includes("get")) { + return [ + "Gateway inference:", + "", + " Route: inference.local", + " Provider: openai-api", + " Model: gpt-5.4", + " Version: 1", + ].join("\n"); + } + return ""; +}; +registry.updateSandbox = () => true; + +process.env.OPENAI_API_KEY = "sk-secret-value"; + +const { setupInference } = require(${onboardPath}); + +(async () => { + await setupInference("test-box", "gpt-5.4", "openai-api", "https://api.openai.com/v1", "OPENAI_API_KEY"); + console.log(JSON.stringify(commands)); +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + }, + }); + + assert.equal(result.status, 0, result.stderr); + const commands = JSON.parse(result.stdout.trim().split("\n").pop()); + assert.equal(commands.length, 4); + assert.match(commands[0].command, /gateway' 'select' 'nemoclaw'/); + assert.match(commands[1].command, /provider' 'create'/); + assert.match(commands[2].command, /provider' 'update' 'openai-api'/); + assert.doesNotMatch(commands[2].command, /'--type'/); + assert.match(commands[3].command, /inference' 'set' '--no-verify'/); + }); + + it("re-prompts for credentials when openshell inference set fails with authorization errors", () => { const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-inference-")); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-apply-auth-retry-")); const fakeBin = path.join(tmpDir, "bin"); - const scriptPath = path.join(tmpDir, "setup-inference-check.js"); + const scriptPath = path.join(tmpDir, "setup-inference-auth-retry-check.js"); const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); const registryPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "registry.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); fs.mkdirSync(fakeBin, { recursive: true }); - fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); const script = String.raw` const runner = require(${runnerPath}); const registry = require(${registryPath}); +const credentials = require(${credentialsPath}); const commands = []; +const answers = ["retry", "sk-good"]; +let inferenceSetCalls = 0; + +credentials.prompt = async () => answers.shift() || ""; runner.run = (command, opts = {}) => { commands.push({ command, env: opts.env || null }); - return { status: 0 }; + if (command.includes("'inference' 'set'")) { + inferenceSetCalls += 1; + if (inferenceSetCalls === 1) { + return { status: 1, stdout: "", stderr: "HTTP 403: forbidden" }; + } + } + return { status: 0, stdout: "", stderr: "" }; }; runner.runCapture = (command) => { if (command.includes("inference") && command.includes("get")) { @@ -202,8 +935,8 @@ runner.runCapture = (command) => { "Gateway inference:", "", " Route: inference.local", - " Provider: nvidia-nim", - " Model: nvidia/nemotron-3-super-120b-a12b", + " Provider: openai-api", + " Model: gpt-5.4", " Version: 1", ].join("\\n"); } @@ -211,13 +944,13 @@ runner.runCapture = (command) => { }; registry.updateSandbox = () => true; -process.env.NVIDIA_API_KEY = "nvapi-secret-value"; +process.env.OPENAI_API_KEY = "sk-bad"; const { setupInference } = require(${onboardPath}); (async () => { - await setupInference("test-box", "nvidia/nemotron-3-super-120b-a12b", "nvidia-nim"); - console.log(JSON.stringify(commands)); + await setupInference("test-box", "gpt-5.4", "openai-api", "https://api.openai.com/v1", "OPENAI_API_KEY"); + console.log(JSON.stringify({ commands, key: process.env.OPENAI_API_KEY, inferenceSetCalls })); })().catch((error) => { console.error(error); process.exit(1); @@ -235,59 +968,56 @@ const { setupInference } = require(${onboardPath}); }, }); - expect(result.status).toBe(0); - const commands = JSON.parse(result.stdout.trim().split("\n").pop()); - assert.equal(commands.length, 3); - assert.match(commands[0].command, /gateway' 'select' 'nemoclaw'/); - assert.match(commands[1].command, /'--credential' 'NVIDIA_API_KEY'/); - assert.doesNotMatch(commands[1].command, /nvapi-secret-value/); - assert.match(commands[1].command, /provider' 'create'/); - assert.match(commands[2].command, /inference' 'set'/); + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim().split("\n").pop()); + assert.equal(payload.key, "sk-good"); + assert.equal(payload.inferenceSetCalls, 2); + const providerEnvs = payload.commands + .filter((entry) => entry.command.includes("'provider'")) + .map((entry) => entry.env && entry.env.OPENAI_API_KEY) + .filter(Boolean); + assert.deepEqual(providerEnvs, ["sk-bad", "sk-good"]); }); - it("uses native Anthropic provider creation without embedding the secret in argv", () => { + it("returns control to provider selection when inference apply recovery chooses back", () => { const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-anthropic-")); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-apply-back-")); const fakeBin = path.join(tmpDir, "bin"); - const scriptPath = path.join(tmpDir, "setup-anthropic-check.js"); + const scriptPath = path.join(tmpDir, "setup-inference-apply-back-check.js"); const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); const registryPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "registry.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); fs.mkdirSync(fakeBin, { recursive: true }); - fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); const script = String.raw` const runner = require(${runnerPath}); const registry = require(${registryPath}); +const credentials = require(${credentialsPath}); const commands = []; +credentials.prompt = async () => "back"; runner.run = (command, opts = {}) => { commands.push({ command, env: opts.env || null }); - return { status: 0 }; -}; -runner.runCapture = (command) => { - if (command.includes("inference") && command.includes("get")) { - return [ - "Gateway inference:", - "", - " Route: inference.local", - " Provider: anthropic-prod", - " Model: claude-sonnet-4-5", - " Version: 1", - ].join("\n"); + if (command.includes("'inference' 'set'")) { + return { status: 1, stdout: "", stderr: "HTTP 404: model not found" }; } - return ""; + return { status: 0, stdout: "", stderr: "" }; }; +runner.runCapture = () => ""; registry.updateSandbox = () => true; -process.env.ANTHROPIC_API_KEY = "sk-ant-secret-value"; +process.env.OPENAI_API_KEY = "sk-secret-value"; const { setupInference } = require(${onboardPath}); (async () => { - await setupInference("test-box", "claude-sonnet-4-5", "anthropic-prod", "https://api.anthropic.com", "ANTHROPIC_API_KEY"); - console.log(JSON.stringify(commands)); + const result = await setupInference("test-box", "gpt-5.4", "openai-api", "https://api.openai.com/v1", "OPENAI_API_KEY"); + console.log(JSON.stringify({ result, commands })); })().catch((error) => { console.error(error); process.exit(1); @@ -306,37 +1036,105 @@ const { setupInference } = require(${onboardPath}); }); assert.equal(result.status, 0, result.stderr); - const commands = JSON.parse(result.stdout.trim().split("\n").pop()); - assert.equal(commands.length, 3); - assert.match(commands[0].command, /gateway' 'select' 'nemoclaw'/); - assert.match(commands[1].command, /'--type' 'anthropic'/); - assert.match(commands[1].command, /'--credential' 'ANTHROPIC_API_KEY'/); - assert.doesNotMatch(commands[1].command, /sk-ant-secret-value/); - assert.match(commands[2].command, /'--provider' 'anthropic-prod'/); + const payload = JSON.parse(result.stdout.trim().split("\n").pop()); + assert.deepEqual(payload.result, { retry: "selection" }); + assert.equal( + payload.commands.filter((entry) => entry.command.includes("'inference' 'set'")).length, + 1, + ); }); - it("updates OpenAI-compatible providers without passing an unsupported --type flag", () => { + it("uses split curl timeout args and does not mislabel curl usage errors as timeouts", () => { + const source = fs.readFileSync( + path.join(import.meta.dirname, "..", "bin", "lib", "onboard.js"), + "utf-8", + ); + + assert.match(source, /return \["--connect-timeout", "10", "--max-time", "60", "--http1\.1"\];/); + assert.match(source, /failure\.curlStatus === 2/); + assert.match(source, /local curl invocation error/); + }); + + it("suppresses expected provider-create AlreadyExists noise when update succeeds", () => { + const source = fs.readFileSync( + path.join(import.meta.dirname, "..", "bin", "lib", "onboard.js"), + "utf-8", + ); + + assert.match(source, /stdio: \["ignore", "pipe", "pipe"\]/); + assert.match(source, /console\.log\(`✓ Created provider \$\{name\}`\)/); + assert.match(source, /console\.log\(`✓ Updated provider \$\{name\}`\)/); + }); + + it("starts the sandbox step before prompting for the sandbox name", () => { + const source = fs.readFileSync( + path.join(import.meta.dirname, "..", "bin", "lib", "onboard.js"), + "utf-8", + ); + + assert.match( + source, + /startRecordedStep\("sandbox", \{ sandboxName, provider, model \}\);\s*sandboxName = await createSandbox\(gpu, model, provider, preferredInferenceApi, sandboxName\);/, + ); + }); + + it("prints numbered step headers even when onboarding skips resumed steps", () => { + const source = fs.readFileSync( + path.join(import.meta.dirname, "..", "bin", "lib", "onboard.js"), + "utf-8", + ); + + assert.match(source, /const ONBOARD_STEP_INDEX = \{/); + assert.match(source, /function skippedStepMessage\(stepName, detail, reason = "resume"\)/); + assert.match(source, /step\(stepInfo\.number, 7, stepInfo\.title\);/); + assert.match(source, /skippedStepMessage\("openclaw", sandboxName\)/); + assert.match( + source, + /skippedStepMessage\("policies", \(recordedPolicyPresets \|\| \[\]\)\.join\(", "\)\)/, + ); + }); + + it("surfaces sandbox-create phases and silence heartbeats during long image operations", () => { + const source = fs.readFileSync( + path.join(import.meta.dirname, "..", "bin", "lib", "onboard.js"), + "utf-8", + ); + + assert.match(source, /function setPhase\(nextPhase\)/); + assert.match(source, /Building sandbox image\.\.\./); + assert.match(source, /Uploading image into OpenShell gateway\.\.\./); + assert.match(source, /Creating sandbox in gateway\.\.\./); + assert.match(source, /Still building sandbox image\.\.\. \(\$\{elapsed\}s elapsed\)/); + assert.match( + source, + /Still uploading image into OpenShell gateway\.\.\. \(\$\{elapsed\}s elapsed\)/, + ); + }); + + it("hydrates stored provider credentials when setupInference runs without process env set", () => { const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-openai-update-")); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-resume-cred-")); const fakeBin = path.join(tmpDir, "bin"); - const scriptPath = path.join(tmpDir, "setup-openai-update-check.js"); + const scriptPath = path.join(tmpDir, "setup-resume-credential-check.js"); const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); const registryPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "registry.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); fs.mkdirSync(fakeBin, { recursive: true }); - fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); const script = String.raw` const runner = require(${runnerPath}); const registry = require(${registryPath}); +const credentials = require(${credentialsPath}); const commands = []; -let callIndex = 0; runner.run = (command, opts = {}) => { commands.push({ command, env: opts.env || null }); - callIndex += 1; - return { status: callIndex === 2 ? 1 : 0 }; + return { status: 0 }; }; runner.runCapture = (command) => { if (command.includes("inference") && command.includes("get")) { @@ -353,13 +1151,14 @@ runner.runCapture = (command) => { }; registry.updateSandbox = () => true; -process.env.OPENAI_API_KEY = "sk-secret-value"; +credentials.saveCredential("OPENAI_API_KEY", "sk-stored-secret"); +delete process.env.OPENAI_API_KEY; const { setupInference } = require(${onboardPath}); (async () => { await setupInference("test-box", "gpt-5.4", "openai-api", "https://api.openai.com/v1", "OPENAI_API_KEY"); - console.log(JSON.stringify(commands)); + console.log(JSON.stringify({ commands, openai: process.env.OPENAI_API_KEY || null })); })().catch((error) => { console.error(error); process.exit(1); @@ -378,13 +1177,10 @@ const { setupInference } = require(${onboardPath}); }); assert.equal(result.status, 0, result.stderr); - const commands = JSON.parse(result.stdout.trim().split("\n").pop()); - assert.equal(commands.length, 4); - assert.match(commands[0].command, /gateway' 'select' 'nemoclaw'/); - assert.match(commands[1].command, /provider' 'create'/); - assert.match(commands[2].command, /provider' 'update' 'openai-api'/); - assert.doesNotMatch(commands[2].command, /'--type'/); - assert.match(commands[3].command, /inference' 'set' '--no-verify'/); + const payload = JSON.parse(result.stdout.trim().split("\n").pop()); + assert.equal(payload.openai, "sk-stored-secret"); + assert.equal(payload.commands[1].env.OPENAI_API_KEY, "sk-stored-secret"); + assert.doesNotMatch(payload.commands[1].command, /sk-stored-secret/); }); it("drops stale local sandbox registry entries when the live sandbox is gone", () => { @@ -397,7 +1193,9 @@ const { setupInference } = require(${onboardPath}); const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); fs.mkdirSync(fakeBin, { recursive: true }); - fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); const script = String.raw` const registry = require(${registryPath}); @@ -448,7 +1246,9 @@ console.log(JSON.stringify({ liveExists, sandbox: registry.getSandbox("my-assist const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); fs.mkdirSync(fakeBin, { recursive: true }); - fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); const script = String.raw` const runner = require(${runnerPath}); @@ -466,6 +1266,7 @@ runner.run = (command, opts = {}) => { runner.runCapture = (command) => { if (command.includes("'sandbox' 'get' 'my-assistant'")) return ""; if (command.includes("'sandbox' 'list'")) return "my-assistant Ready"; + if (command.includes("sandbox exec my-assistant curl -sf http://localhost:18789/")) return "ok"; return ""; }; registry.registerSandbox = () => true; @@ -519,7 +1320,9 @@ const { createSandbox } = require(${onboardPath}); assert.ok(payloadLine, `expected JSON payload in stdout:\n${result.stdout}`); const payload = JSON.parse(payloadLine); assert.equal(payload.sandboxName, "my-assistant"); - const createCommand = payload.commands.find((entry) => entry.command.includes("'sandbox' 'create'")); + const createCommand = payload.commands.find((entry) => + entry.command.includes("'sandbox' 'create'"), + ); assert.ok(createCommand, "expected sandbox create command"); assert.match(createCommand.command, /'nemoclaw-start'/); assert.doesNotMatch(createCommand.command, /'--upload'/); @@ -527,6 +1330,99 @@ const { createSandbox } = require(${onboardPath}); assert.doesNotMatch(createCommand.command, /NVIDIA_API_KEY=/); assert.doesNotMatch(createCommand.command, /DISCORD_BOT_TOKEN=/); assert.doesNotMatch(createCommand.command, /SLACK_BOT_TOKEN=/); + assert.ok( + payload.commands.some((entry) => + entry.command.includes("'forward' 'start' '--background' '18789' 'my-assistant'"), + ), + "expected default loopback dashboard forward", + ); + }); + + it("binds the dashboard forward to 0.0.0.0 when CHAT_UI_URL points to a remote host", async () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-remote-forward-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "create-sandbox-remote-forward.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + const registryPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "registry.js")); + const preflightPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "preflight.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); + + const script = String.raw` +const runner = require(${runnerPath}); +const registry = require(${registryPath}); +const preflight = require(${preflightPath}); +const credentials = require(${credentialsPath}); +const childProcess = require("node:child_process"); +const { EventEmitter } = require("node:events"); + +const commands = []; +runner.run = (command, opts = {}) => { + commands.push({ command, env: opts.env || null }); + return { status: 0 }; +}; +runner.runCapture = (command) => { + if (command.includes("'sandbox' 'get' 'my-assistant'")) return ""; + if (command.includes("'sandbox' 'list'")) return "my-assistant Ready"; + if (command.includes("sandbox exec my-assistant curl -sf http://localhost:18789/")) return "ok"; + return ""; +}; +registry.registerSandbox = () => true; +registry.removeSandbox = () => true; +preflight.checkPortAvailable = async () => ({ ok: true }); +credentials.prompt = async () => ""; + +childProcess.spawn = (...args) => { + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + commands.push({ command: args[1][1], env: args[2]?.env || null }); + process.nextTick(() => { + child.stdout.emit("data", Buffer.from("Created sandbox: my-assistant\n")); + child.emit("close", 0); + }); + return child; +}; + +const { createSandbox } = require(${onboardPath}); + +(async () => { + process.env.OPENSHELL_GATEWAY = "nemoclaw"; + process.env.CHAT_UI_URL = "https://chat.example.com"; + await createSandbox(null, "gpt-5.4"); + console.log(JSON.stringify(commands)); +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + NEMOCLAW_NON_INTERACTIVE: "1", + }, + }); + + assert.equal(result.status, 0, result.stderr); + const commands = JSON.parse(result.stdout.trim().split("\n").pop()); + assert.ok( + commands.some((entry) => + entry.command.includes("'forward' 'start' '--background' '0.0.0.0:18789' 'my-assistant'"), + ), + "expected remote dashboard forward target", + ); }); it("continues once the sandbox is Ready even if the create stream never closes", async () => { @@ -542,7 +1438,9 @@ const { createSandbox } = require(${onboardPath}); const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); fs.mkdirSync(fakeBin, { recursive: true }); - fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); const script = String.raw` const runner = require(${runnerPath}); @@ -566,6 +1464,7 @@ runner.runCapture = (command) => { sandboxListCalls += 1; return sandboxListCalls >= 2 ? "my-assistant Ready" : "my-assistant Pending"; } + if (command.includes("sandbox exec my-assistant curl -sf http://localhost:18789/")) return "ok"; return ""; }; registry.registerSandbox = () => true; @@ -647,6 +1546,127 @@ const { createSandbox } = require(${onboardPath}); assert.equal(payload.stderrDestroyCalls, 1); }); + it("restores the dashboard forward when onboarding reuses an existing ready sandbox", async () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-reuse-forward-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "reuse-sandbox-forward.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + const registryPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "registry.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); + + const script = String.raw` +const runner = require(${runnerPath}); +const registry = require(${registryPath}); + +const commands = []; +runner.run = (command, opts = {}) => { + commands.push({ command, env: opts.env || null }); + return { status: 0 }; +}; +runner.runCapture = (command) => { + if (command.includes("'sandbox' 'get' 'my-assistant'")) return "my-assistant"; + if (command.includes("'sandbox' 'list'")) return "my-assistant Ready"; + return ""; +}; +registry.getSandbox = () => ({ name: "my-assistant", gpuEnabled: false }); + +const { createSandbox } = require(${onboardPath}); + +(async () => { + process.env.OPENSHELL_GATEWAY = "nemoclaw"; + process.env.CHAT_UI_URL = "https://chat.example.com"; + const sandboxName = await createSandbox(null, "gpt-5.4", "nvidia-prod", null, "my-assistant"); + console.log(JSON.stringify({ sandboxName, commands })); +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + NEMOCLAW_NON_INTERACTIVE: "1", + }, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim().split("\n").pop()); + assert.equal(payload.sandboxName, "my-assistant"); + assert.ok( + payload.commands.some((entry) => + entry.command.includes("'forward' 'start' '--background' '0.0.0.0:18789' 'my-assistant'"), + ), + "expected dashboard forward restore on sandbox reuse", + ); + assert.ok( + payload.commands.every((entry) => !entry.command.includes("'sandbox' 'create'")), + "did not expect sandbox create when reusing existing sandbox", + ); + }); + + it("prints resume guidance when sandbox image upload times out", () => { + const errors = []; + const originalError = console.error; + console.error = (...args) => errors.push(args.join(" ")); + try { + printSandboxCreateRecoveryHints( + [ + " Pushing image openshell/sandbox-from:123 into gateway nemoclaw", + " [progress] Uploaded to gateway", + "Error: failed to read image export stream", + "Timeout error", + ].join("\n"), + ); + } finally { + console.error = originalError; + } + + const joined = errors.join("\n"); + assert.match(joined, /Hint: image upload into the OpenShell gateway timed out\./); + assert.match(joined, /Recovery: nemoclaw onboard --resume/); + assert.match( + joined, + /Progress reached the gateway upload stage, so resume may be able to reuse existing gateway state\./, + ); + }); + + it("prints resume guidance when sandbox image upload resets after transfer progress", () => { + const errors = []; + const originalError = console.error; + console.error = (...args) => errors.push(args.join(" ")); + try { + printSandboxCreateRecoveryHints( + [ + " Pushing image openshell/sandbox-from:123 into gateway nemoclaw", + " [progress] Uploaded to gateway", + "Error: Connection reset by peer", + ].join("\n"), + ); + } finally { + console.error = originalError; + } + + const joined = errors.join("\n"); + assert.match(joined, /Hint: the image push\/import stream was interrupted\./); + assert.match(joined, /Recovery: nemoclaw onboard --resume/); + assert.match( + joined, + /The image appears to have reached the gateway before the stream failed\./, + ); + }); + it("accepts gateway inference when system inference is separately not configured", () => { const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-inference-get-")); @@ -657,7 +1677,9 @@ const { createSandbox } = require(${onboardPath}); const registryPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "registry.js")); fs.mkdirSync(fakeBin, { recursive: true }); - fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); const script = String.raw` const runner = require(${runnerPath}); @@ -725,7 +1747,9 @@ const { setupInference } = require(${onboardPath}); const registryPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "registry.js")); fs.mkdirSync(fakeBin, { recursive: true }); - fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); const script = String.raw` const runner = require(${runnerPath}); @@ -782,4 +1806,22 @@ const { setupInference } = require(${onboardPath}); assert.equal(commands.length, 3); }); + it("re-prompts on invalid sandbox names instead of exiting in interactive mode", () => { + const source = fs.readFileSync( + path.join(import.meta.dirname, "..", "bin", "lib", "onboard.js"), + "utf-8", + ); + // Extract the promptValidatedSandboxName function body + const fnMatch = source.match( + /async function promptValidatedSandboxName\(\)\s*\{([\s\S]*?)\n\}/, + ); + assert.ok(fnMatch, "promptValidatedSandboxName function not found"); + const fnBody = fnMatch[1]; + // Verify the retry loop exists within this function + assert.match(fnBody, /while\s*\(true\)/); + assert.match(fnBody, /Please try again/); + // Non-interactive still exits within this function + assert.match(fnBody, /isNonInteractive\(\)/); + assert.match(fnBody, /process\.exit\(1\)/); + }); }); diff --git a/test/platform.test.js b/test/platform.test.js index 0eb85277e..0f3c4eeb2 100644 --- a/test/platform.test.js +++ b/test/platform.test.js @@ -3,10 +3,13 @@ import { describe, it, expect } from "vitest"; import path from "node:path"; +import fs from "node:fs"; +import os from "node:os"; import { detectDockerHost, findColimaDockerSocket, + getColimaDockerSocketCandidates, getDockerSocketCandidates, inferContainerRuntime, isUnsupportedMacosRuntime, @@ -17,19 +20,34 @@ import { describe("platform helpers", () => { describe("isWsl", () => { it("detects WSL from environment", () => { - expect(isWsl({ - platform: "linux", - env: { WSL_DISTRO_NAME: "Ubuntu" }, - release: "6.6.87.2-microsoft-standard-WSL2", - })).toBe(true); + expect( + isWsl({ + platform: "linux", + env: { WSL_DISTRO_NAME: "Ubuntu" }, + release: "6.6.87.2-microsoft-standard-WSL2", + }), + ).toBe(true); }); it("does not treat macOS as WSL", () => { - expect(isWsl({ - platform: "darwin", - env: {}, - release: "24.6.0", - })).toBe(false); + expect( + isWsl({ + platform: "darwin", + env: {}, + release: "24.6.0", + }), + ).toBe(false); + }); + + it("detects WSL from /proc version text even without WSL env vars", () => { + expect( + isWsl({ + platform: "linux", + env: {}, + release: "6.6.87-generic", + procVersion: "Linux version 6.6.87.2-microsoft-standard-WSL2", + }), + ).toBe(true); }); }); @@ -48,24 +66,52 @@ describe("platform helpers", () => { }); }); + describe("getColimaDockerSocketCandidates", () => { + it("returns both legacy and config-path Colima sockets", () => { + expect(getColimaDockerSocketCandidates({ home: "/tmp/test-home" })).toEqual([ + "/tmp/test-home/.colima/default/docker.sock", + "/tmp/test-home/.config/colima/default/docker.sock", + ]); + }); + }); + describe("findColimaDockerSocket", () => { it("finds the first available Colima socket", () => { const home = "/tmp/test-home"; const sockets = new Set([path.join(home, ".config/colima/default/docker.sock")]); const existsSync = (socketPath) => sockets.has(socketPath); - expect(findColimaDockerSocket({ home, existsSync })).toBe(path.join(home, ".config/colima/default/docker.sock")); + expect(findColimaDockerSocket({ home, existsSync })).toBe( + path.join(home, ".config/colima/default/docker.sock"), + ); + }); + + it("returns null when no Colima socket exists", () => { + expect( + findColimaDockerSocket({ home: "/tmp/test-home", existsSync: () => false }), + ).toBeNull(); + }); + + it("uses fs.existsSync when no custom existsSync is provided", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-colima-")); + const socketPath = path.join(home, ".config/colima/default/docker.sock"); + fs.mkdirSync(path.dirname(socketPath), { recursive: true }); + fs.writeFileSync(socketPath, ""); + + expect(findColimaDockerSocket({ home })).toBe(socketPath); }); }); describe("detectDockerHost", () => { it("respects an existing DOCKER_HOST", () => { - expect(detectDockerHost({ - env: { DOCKER_HOST: "unix:///custom/docker.sock" }, - platform: "darwin", - home: "/tmp/test-home", - existsSync: () => false, - })).toEqual({ + expect( + detectDockerHost({ + env: { DOCKER_HOST: "unix:///custom/docker.sock" }, + platform: "darwin", + home: "/tmp/test-home", + existsSync: () => false, + }), + ).toEqual({ dockerHost: "unix:///custom/docker.sock", source: "env", socketPath: null, @@ -100,12 +146,27 @@ describe("platform helpers", () => { }); it("returns null when no auto-detected socket is available", () => { - expect(detectDockerHost({ - env: {}, - platform: "linux", - home: "/tmp/test-home", - existsSync: () => false, - })).toBe(null); + expect( + detectDockerHost({ + env: {}, + platform: "linux", + home: "/tmp/test-home", + existsSync: () => false, + }), + ).toBe(null); + }); + + it("uses fs.existsSync when no custom existsSync is provided", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-docker-")); + const socketPath = path.join(home, ".docker/run/docker.sock"); + fs.mkdirSync(path.dirname(socketPath), { recursive: true }); + fs.writeFileSync(socketPath, ""); + + expect(detectDockerHost({ env: {}, platform: "darwin", home })).toEqual({ + dockerHost: `unix://${socketPath}`, + source: "socket", + socketPath, + }); }); }); @@ -121,6 +182,12 @@ describe("platform helpers", () => { it("detects Colima", () => { expect(inferContainerRuntime("Server: Colima\n Docker Engine - Community")).toBe("colima"); }); + + it("detects plain Docker and unknown output", () => { + expect(inferContainerRuntime("Docker Engine - Community")).toBe("docker"); + expect(inferContainerRuntime("")).toBe("unknown"); + expect(inferContainerRuntime("some unrelated runtime")).toBe("unknown"); + }); }); describe("isUnsupportedMacosRuntime", () => { @@ -134,10 +201,26 @@ describe("platform helpers", () => { }); describe("shouldPatchCoredns", () => { - it("patches CoreDNS for Colima only", () => { - expect(shouldPatchCoredns("colima")).toBe(true); - expect(shouldPatchCoredns("docker-desktop")).toBe(false); - expect(shouldPatchCoredns("docker")).toBe(false); + it("patches on non-WSL runtimes", () => { + const nonWslOpts = { platform: "darwin", env: {} }; + expect(shouldPatchCoredns("colima", nonWslOpts)).toBe(true); + expect(shouldPatchCoredns("docker-desktop", nonWslOpts)).toBe(true); + expect(shouldPatchCoredns("docker", nonWslOpts)).toBe(true); + expect(shouldPatchCoredns("podman", nonWslOpts)).toBe(true); + }); + + it("skips unknown runtimes", () => { + expect(shouldPatchCoredns("unknown")).toBe(false); + }); + + it("skips on WSL", () => { + expect( + shouldPatchCoredns("docker-desktop", { + platform: "linux", + env: { WSL_DISTRO_NAME: "Ubuntu" }, + release: "6.6.87.2-microsoft-standard-WSL2", + }), + ).toBe(false); }); }); }); diff --git a/test/policies.test.js b/test/policies.test.js index 5b3782c03..43e820dfa 100644 --- a/test/policies.test.js +++ b/test/policies.test.js @@ -2,9 +2,95 @@ // SPDX-License-Identifier: Apache-2.0 import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { describe, it, expect } from "vitest"; +import { spawnSync } from "node:child_process"; import policies from "../bin/lib/policies"; +const REPO_ROOT = path.join(import.meta.dirname, ".."); +const CLI_PATH = JSON.stringify(path.join(REPO_ROOT, "bin", "nemoclaw.js")); +const CREDENTIALS_PATH = JSON.stringify(path.join(REPO_ROOT, "bin", "lib", "credentials.js")); +const POLICIES_PATH = JSON.stringify(path.join(REPO_ROOT, "bin", "lib", "policies.js")); +const REGISTRY_PATH = JSON.stringify(path.join(REPO_ROOT, "bin", "lib", "registry.js")); +const SELECT_FROM_LIST_ITEMS = [ + { name: "npm", description: "npm and Yarn registry access" }, + { name: "pypi", description: "Python Package Index (PyPI) access" }, +]; + +function runPolicyAdd(confirmAnswer) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-add-")); + const scriptPath = path.join(tmpDir, "policy-add-check.js"); + const script = String.raw` +const registry = require(${REGISTRY_PATH}); +const policies = require(${POLICIES_PATH}); +const credentials = require(${CREDENTIALS_PATH}); +const calls = []; +policies.selectFromList = async () => "pypi"; +credentials.prompt = async (message) => { + calls.push({ type: "prompt", message }); + return ${JSON.stringify(confirmAnswer)}; +}; +registry.getSandbox = (name) => (name === "test-sandbox" ? { name } : null); +registry.listSandboxes = () => ({ sandboxes: [{ name: "test-sandbox" }] }); +policies.listPresets = () => [ + { name: "npm", description: "npm and Yarn registry access" }, + { name: "pypi", description: "Python Package Index (PyPI) access" }, +]; +policies.getAppliedPresets = () => []; +policies.applyPreset = (sandboxName, presetName) => { + calls.push({ type: "apply", sandboxName, presetName }); +}; +process.argv = ["node", "nemoclaw.js", "test-sandbox", "policy-add"]; +require(${CLI_PATH}); +setImmediate(() => { + process.stdout.write(JSON.stringify(calls)); +}); +`; + + fs.writeFileSync(scriptPath, script); + + return spawnSync(process.execPath, [scriptPath], { + cwd: REPO_ROOT, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + }, + }); +} + +function runSelectFromList(input, { applied = [] } = {}) { + const script = String.raw` +const { selectFromList } = require(${POLICIES_PATH}); +const items = JSON.parse(process.env.NEMOCLAW_TEST_ITEMS); +const options = JSON.parse(process.env.NEMOCLAW_TEST_OPTIONS || "{}"); + +selectFromList(items, options) + .then((value) => { + process.stdout.write(String(value) + "\n"); + }) + .catch((error) => { + const message = error && error.message ? error.message : String(error); + process.stderr.write(message); + process.exit(1); + }); +`; + + return spawnSync(process.execPath, ["-e", script], { + cwd: REPO_ROOT, + encoding: "utf-8", + timeout: 5000, + input, + env: { + ...process.env, + NEMOCLAW_TEST_ITEMS: JSON.stringify(SELECT_FROM_LIST_ITEMS), + NEMOCLAW_TEST_OPTIONS: JSON.stringify({ applied }), + }, + }); +} + describe("policies", () => { describe("listPresets", () => { it("returns all 9 presets", () => { @@ -20,8 +106,21 @@ describe("policies", () => { }); it("returns expected preset names", () => { - const names = policies.listPresets().map((p) => p.name).sort(); - const expected = ["discord", "docker", "huggingface", "jira", "npm", "outlook", "pypi", "slack", "telegram"]; + const names = policies + .listPresets() + .map((p) => p.name) + .sort(); + const expected = [ + "discord", + "docker", + "huggingface", + "jira", + "npm", + "outlook", + "pypi", + "slack", + "telegram", + ]; expect(names).toEqual(expected); }); }); @@ -90,7 +189,10 @@ describe("policies", () => { process.env.NEMOCLAW_OPENSHELL_BIN = "/tmp/fake path/openshell"; try { const cmd = policies.buildPolicySetCommand("/tmp/policy.yaml", "my-assistant"); - assert.equal(cmd, "'/tmp/fake path/openshell' policy set --policy '/tmp/policy.yaml' --wait 'my-assistant'"); + assert.equal( + cmd, + "'/tmp/fake path/openshell' policy set --policy '/tmp/policy.yaml' --wait 'my-assistant'", + ); } finally { delete process.env.NEMOCLAW_OPENSHELL_BIN; } @@ -104,6 +206,258 @@ describe("policies", () => { }); }); + describe("extractPresetEntries", () => { + it("returns null for null input", () => { + expect(policies.extractPresetEntries(null)).toBe(null); + }); + + it("returns null for undefined input", () => { + expect(policies.extractPresetEntries(undefined)).toBe(null); + }); + + it("returns null for empty string", () => { + expect(policies.extractPresetEntries("")).toBe(null); + }); + + it("returns null when no network_policies section exists", () => { + const content = "preset:\n name: test\n description: test preset"; + expect(policies.extractPresetEntries(content)).toBe(null); + }); + + it("extracts indented entries from network_policies section", () => { + const content = [ + "preset:", + " name: test", + "", + "network_policies:", + " test_rule:", + " name: test_rule", + " endpoints:", + " - host: example.com", + " port: 443", + ].join("\n"); + const entries = policies.extractPresetEntries(content); + expect(entries).toContain("test_rule:"); + expect(entries).toContain("host: example.com"); + expect(entries).toContain("port: 443"); + }); + + it("strips trailing whitespace from extracted entries", () => { + const content = "network_policies:\n rule:\n name: rule\n\n\n"; + const entries = policies.extractPresetEntries(content); + expect(entries).not.toMatch(/\n$/); + }); + + it("works on every real preset file", () => { + for (const p of policies.listPresets()) { + const content = policies.loadPreset(p.name); + const entries = policies.extractPresetEntries(content); + expect(entries).toBeTruthy(); + expect(entries).toContain("endpoints:"); + } + }); + + it("does not include preset metadata header", () => { + const content = [ + "preset:", + " name: test", + " description: desc", + "", + "network_policies:", + " rule:", + " name: rule", + ].join("\n"); + const entries = policies.extractPresetEntries(content); + expect(entries).not.toContain("preset:"); + expect(entries).not.toContain("description:"); + }); + }); + + describe("parseCurrentPolicy", () => { + it("returns empty string for null input", () => { + expect(policies.parseCurrentPolicy(null)).toBe(""); + }); + + it("returns empty string for undefined input", () => { + expect(policies.parseCurrentPolicy(undefined)).toBe(""); + }); + + it("returns empty string for empty string input", () => { + expect(policies.parseCurrentPolicy("")).toBe(""); + }); + + it("strips metadata header before --- separator", () => { + const raw = [ + "Version: 3", + "Hash: abc123", + "Updated: 2026-03-26", + "---", + "version: 1", + "", + "network_policies:", + " rule: {}", + ].join("\n"); + const result = policies.parseCurrentPolicy(raw); + expect(result).toBe("version: 1\n\nnetwork_policies:\n rule: {}"); + expect(result).not.toContain("Hash:"); + expect(result).not.toContain("Updated:"); + }); + + it("returns raw content when no --- separator exists", () => { + const raw = "version: 1\nnetwork_policies:\n rule: {}"; + expect(policies.parseCurrentPolicy(raw)).toBe(raw); + }); + + it("trims whitespace around extracted YAML", () => { + const raw = "Header: value\n---\n \nversion: 1\n "; + const result = policies.parseCurrentPolicy(raw); + expect(result).toBe("version: 1"); + }); + + it("handles --- appearing as first line", () => { + const raw = "---\nversion: 1\nnetwork_policies: {}"; + const result = policies.parseCurrentPolicy(raw); + expect(result).toBe("version: 1\nnetwork_policies: {}"); + }); + + it("drops metadata-only or truncated policy reads", () => { + const raw = "Version: 3\nHash: abc123"; + expect(policies.parseCurrentPolicy(raw)).toBe(""); + }); + + it("drops non-policy error output instead of treating it as YAML", () => { + const raw = "Error: failed to parse sandbox policy YAML"; + expect(policies.parseCurrentPolicy(raw)).toBe(""); + }); + + it("drops syntactically invalid or truncated YAML bodies", () => { + const raw = "Version: 3\n---\nversion: 1\nnetwork_policies"; + expect(policies.parseCurrentPolicy(raw)).toBe(""); + }); + }); + + describe("mergePresetIntoPolicy", () => { + // Legacy list-style entries (backward compat — uses text-based fallback) + const sampleEntries = " - host: example.com\n allow: true"; + + it("appends network_policies when current policy has content but no version header", () => { + const versionless = "some_key:\n foo: bar"; + const merged = policies.mergePresetIntoPolicy(versionless, sampleEntries); + expect(merged).toContain("version:"); + expect(merged).toContain("some_key:"); + expect(merged).toContain("network_policies:"); + expect(merged).toContain("example.com"); + }); + + it("appends preset entries when current policy has network_policies but no version", () => { + const versionlessWithNp = "network_policies:\n - host: existing.com\n allow: true"; + const merged = policies.mergePresetIntoPolicy(versionlessWithNp, sampleEntries); + expect(merged).toContain("version:"); + expect(merged).toContain("existing.com"); + expect(merged).toContain("example.com"); + }); + + it("keeps existing version when present", () => { + const withVersion = "version: 2\n\nnetwork_policies:\n - host: old.com"; + const merged = policies.mergePresetIntoPolicy(withVersion, sampleEntries); + expect(merged).toContain("version: 2"); + expect(merged).toContain("example.com"); + }); + + it("returns version + network_policies when current policy is empty", () => { + const merged = policies.mergePresetIntoPolicy("", sampleEntries); + expect(merged).toContain("version: 1"); + expect(merged).toContain("network_policies:"); + expect(merged).toContain("example.com"); + }); + + it("rebuilds from a clean scaffold when current policy read is truncated", () => { + const merged = policies.mergePresetIntoPolicy("Version: 3\nHash: abc123", sampleEntries); + expect(merged).toBe( + "version: 1\n\nnetwork_policies:\n - host: example.com\n allow: true", + ); + }); + + it("adds a blank line after synthesized version headers", () => { + const merged = policies.mergePresetIntoPolicy("some_key:\n foo: bar", sampleEntries); + expect(merged.startsWith("version: 1\n\nsome_key:")).toBe(true); + }); + + // --- Structured merge tests (real preset format) --- + const realisticEntries = + " pypi_access:\n" + + " name: pypi_access\n" + + " endpoints:\n" + + " - host: pypi.org\n" + + " port: 443\n" + + " access: full\n" + + " binaries:\n" + + " - { path: /usr/bin/python3* }\n"; + + it("uses structured YAML merge for real preset entries", () => { + const current = + "version: 1\n\n" + + "network_policies:\n" + + " npm_yarn:\n" + + " name: npm_yarn\n" + + " endpoints:\n" + + " - host: registry.npmjs.org\n" + + " port: 443\n" + + " access: full\n" + + " binaries:\n" + + " - { path: /usr/local/bin/npm* }\n"; + const merged = policies.mergePresetIntoPolicy(current, realisticEntries); + expect(merged).toContain("npm_yarn"); + expect(merged).toContain("registry.npmjs.org"); + expect(merged).toContain("pypi_access"); + expect(merged).toContain("pypi.org"); + expect(merged).toContain("version: 1"); + }); + + it("deduplicates on policy name collision (preset overrides existing)", () => { + const current = + "version: 1\n\n" + + "network_policies:\n" + + " pypi_access:\n" + + " name: pypi_access\n" + + " endpoints:\n" + + " - host: old-pypi.example.com\n" + + " port: 443\n" + + " access: full\n" + + " binaries:\n" + + " - { path: /usr/bin/pip* }\n"; + const merged = policies.mergePresetIntoPolicy(current, realisticEntries); + expect(merged).toContain("pypi.org"); + expect(merged).not.toContain("old-pypi.example.com"); + }); + + it("preserves non-network sections during structured merge", () => { + const current = + "version: 1\n\n" + + "filesystem_policy:\n" + + " include_workdir: true\n" + + " read_only:\n" + + " - /usr\n\n" + + "process:\n" + + " run_as_user: sandbox\n\n" + + "network_policies:\n" + + " existing:\n" + + " name: existing\n" + + " endpoints:\n" + + " - host: api.example.com\n" + + " port: 443\n" + + " access: full\n" + + " binaries:\n" + + " - { path: /usr/local/bin/node* }\n"; + const merged = policies.mergePresetIntoPolicy(current, realisticEntries); + expect(merged).toContain("filesystem_policy"); + expect(merged).toContain("include_workdir"); + expect(merged).toContain("run_as_user: sandbox"); + expect(merged).toContain("existing"); + expect(merged).toContain("pypi_access"); + }); + }); + describe("preset YAML schema", () => { it("no preset has rules at NetworkPolicyRuleDef level", () => { // rules must be inside endpoints, not as sibling of endpoints/binaries @@ -116,7 +470,7 @@ describe("policies", () => { // rules: at 8+ space indent (inside an endpoint) is correct if (/^\s{4}rules:/.test(line)) { expect.unreachable( - `${p.name} line ${i + 1}: rules at policy level (should be inside endpoint)` + `${p.name} line ${i + 1}: rules at policy level (should be inside endpoint)`, ); } } @@ -158,4 +512,96 @@ describe("policies", () => { } }); }); + + describe("selectFromList", () => { + it("returns preset name by number from stdin input", () => { + const result = runSelectFromList("1\n"); + + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe("npm"); + expect(result.stderr).toContain("Choose preset [1]:"); + }); + + it("uses the first preset as the default when input is empty", () => { + const result = runSelectFromList("\n"); + + expect(result.status).toBe(0); + expect(result.stderr).toContain("Choose preset [1]:"); + expect(result.stdout.trim()).toBe("npm"); + }); + + it("defaults to the first not-applied preset", () => { + const result = runSelectFromList("\n", { applied: ["npm"] }); + + expect(result.status).toBe(0); + expect(result.stderr).toContain("Choose preset [2]:"); + expect(result.stdout.trim()).toBe("pypi"); + }); + + it("rejects selecting an already-applied preset", () => { + const result = runSelectFromList("1\n", { applied: ["npm"] }); + + expect(result.status).toBe(0); + expect(result.stderr).toContain("Preset 'npm' is already applied."); + expect(result.stdout.trim()).toBe("null"); + }); + + it("rejects out-of-range preset number", () => { + const result = runSelectFromList("99\n"); + + expect(result.status).toBe(0); + expect(result.stderr).toContain("Invalid preset number."); + expect(result.stdout.trim()).toBe("null"); + }); + + it("rejects non-numeric preset input", () => { + const result = runSelectFromList("npm\n"); + + expect(result.status).toBe(0); + expect(result.stderr).toContain("Invalid preset number."); + expect(result.stdout.trim()).toBe("null"); + }); + + it("prints numbered list with applied markers, legend, and default prompt", () => { + const result = runSelectFromList("2\n", { applied: ["npm"] }); + + expect(result.status).toBe(0); + expect(result.stderr).toMatch(/Available presets:/); + expect(result.stderr).toMatch(/1\) ● npm — npm and Yarn registry access/); + expect(result.stderr).toMatch(/2\) ○ pypi — Python Package Index \(PyPI\) access/); + expect(result.stderr).toMatch(/● applied, ○ not applied/); + expect(result.stderr).toMatch(/Choose preset \[2\]:/); + expect(result.stdout.trim()).toBe("pypi"); + }); + }); + + describe("policy-add confirmation", () => { + it("prompts for confirmation before applying a preset", () => { + const result = runPolicyAdd("y"); + + expect(result.status).toBe(0); + const calls = JSON.parse(result.stdout.trim()); + expect(calls).toContainEqual({ + type: "prompt", + message: " Apply 'pypi' to sandbox 'test-sandbox'? [Y/n]: ", + }); + expect(calls).toContainEqual({ + type: "apply", + sandboxName: "test-sandbox", + presetName: "pypi", + }); + }); + + it("skips applying the preset when confirmation is declined", () => { + const result = runPolicyAdd("n"); + + expect(result.status).toBe(0); + const calls = JSON.parse(result.stdout.trim()); + expect(calls).toContainEqual({ + type: "prompt", + message: " Apply 'pypi' to sandbox 'test-sandbox'? [Y/n]: ", + }); + expect(calls.some((call) => call.type === "apply")).toBeFalsy(); + }); + }); }); diff --git a/test/preflight.test.js b/test/preflight.test.js deleted file mode 100644 index 90e1f4d9b..000000000 --- a/test/preflight.test.js +++ /dev/null @@ -1,116 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, expect, it } from "vitest"; - -import { checkPortAvailable } from "../bin/lib/preflight"; - -describe("checkPortAvailable", () => { - it("falls through to the probe when lsof output is empty", async () => { - let probedPort = null; - const result = await checkPortAvailable(18789, { - lsofOutput: "", - probeImpl: async (port) => { - probedPort = port; - return { ok: true }; - }, - }); - - expect(probedPort).toBe(18789); - expect(result).toEqual({ ok: true }); - }); - - it("probe catches occupied port even when lsof returns empty", async () => { - const result = await checkPortAvailable(18789, { - lsofOutput: "", - probeImpl: async () => ({ - ok: false, - process: "unknown", - pid: null, - reason: "port 18789 is in use (EADDRINUSE)", - }), - }); - - expect(result.ok).toBe(false); - expect(result.process).toBe("unknown"); - expect(result.reason).toContain("EADDRINUSE"); - }); - - it("parses process and PID from lsof output", async () => { - const lsofOutput = [ - "COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME", - "openclaw 12345 root 7u IPv4 54321 0t0 TCP *:18789 (LISTEN)", - ].join("\n"); - const result = await checkPortAvailable(18789, { lsofOutput }); - - expect(result.ok).toBe(false); - expect(result.process).toBe("openclaw"); - expect(result.pid).toBe(12345); - expect(result.reason).toContain("openclaw"); - }); - - it("picks first listener when lsof shows multiple", async () => { - const lsofOutput = [ - "COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME", - "gateway 111 root 7u IPv4 54321 0t0 TCP *:18789 (LISTEN)", - "node 222 root 8u IPv4 54322 0t0 TCP *:18789 (LISTEN)", - ].join("\n"); - const result = await checkPortAvailable(18789, { lsofOutput }); - - expect(result.ok).toBe(false); - expect(result.process).toBe("gateway"); - expect(result.pid).toBe(111); - }); - - it("returns ok for a free port probe", async () => { - const result = await checkPortAvailable(8080, { - skipLsof: true, - probeImpl: async () => ({ ok: true }), - }); - - expect(result).toEqual({ ok: true }); - }); - - it("returns occupied for EADDRINUSE probe results", async () => { - const result = await checkPortAvailable(8080, { - skipLsof: true, - probeImpl: async () => ({ - ok: false, - process: "unknown", - pid: null, - reason: "port 8080 is in use (EADDRINUSE)", - }), - }); - - expect(result.ok).toBe(false); - expect(result.process).toBe("unknown"); - expect(result.reason).toContain("EADDRINUSE"); - }); - - it("treats restricted probe environments as inconclusive instead of occupied", async () => { - const result = await checkPortAvailable(8080, { - skipLsof: true, - probeImpl: async () => ({ - ok: true, - warning: "port probe skipped: listen EPERM: operation not permitted 127.0.0.1", - }), - }); - - expect(result.ok).toBe(true); - expect(result.warning).toContain("EPERM"); - }); - - it("defaults to port 18789 when no port is given", async () => { - let probedPort = null; - const result = await checkPortAvailable(undefined, { - skipLsof: true, - probeImpl: async (port) => { - probedPort = port; - return { ok: true }; - }, - }); - - expect(probedPort).toBe(18789); - expect(result.ok).toBe(true); - }); -}); diff --git a/test/registry.test.js b/test/registry.test.js index 720209460..80fd0dded 100644 --- a/test/registry.test.js +++ b/test/registry.test.js @@ -66,6 +66,15 @@ describe("registry", () => { expect(registry.updateSandbox("nope", {})).toBe(false); }); + it("updateSandbox rejects name changes", () => { + registry.registerSandbox({ name: "orig" }); + expect(registry.updateSandbox("orig", { name: "renamed" })).toBe(false); + // Original entry unchanged + expect(registry.getSandbox("orig").name).toBe("orig"); + // No ghost entry under new name + expect(registry.getSandbox("renamed")).toBe(null); + }); + it("removeSandbox deletes and shifts default", () => { registry.registerSandbox({ name: "x" }); registry.registerSandbox({ name: "y" }); @@ -106,3 +115,178 @@ describe("registry", () => { expect(sandboxes.length).toBe(0); }); }); + +describe("atomic writes", () => { + const regDir = path.dirname(regFile); + + beforeEach(() => { + if (fs.existsSync(regFile)) fs.unlinkSync(regFile); + // Clean up any leftover tmp files + if (fs.existsSync(regDir)) { + for (const f of fs.readdirSync(regDir)) { + if (f.startsWith("sandboxes.json.tmp.")) { + fs.unlinkSync(path.join(regDir, f)); + } + } + } + }); + + it("save() writes via temp file + rename (no partial writes on disk)", () => { + registry.registerSandbox({ name: "atomic-test" }); + // File must exist and be valid JSON after save + const raw = fs.readFileSync(regFile, "utf-8"); + const data = JSON.parse(raw); + expect(data.sandboxes["atomic-test"].name).toBe("atomic-test"); + // No leftover .tmp files + const tmpFiles = fs.readdirSync(regDir).filter((f) => f.startsWith("sandboxes.json.tmp.")); + expect(tmpFiles).toHaveLength(0); + }); + + it("save() cleans up temp file when rename fails", () => { + fs.mkdirSync(regDir, { recursive: true }); + fs.writeFileSync(regFile, '{"sandboxes":{},"defaultSandbox":null}', { mode: 0o600 }); + + // Stub renameSync so writeFileSync succeeds (temp file is created) + // but the rename step throws — exercising the cleanup branch. + const original = fs.renameSync; + fs.renameSync = () => { + throw Object.assign(new Error("EACCES"), { code: "EACCES" }); + }; + try { + expect(() => registry.save({ sandboxes: {}, defaultSandbox: null })).toThrow("EACCES"); + } finally { + fs.renameSync = original; + } + // The save() catch block should have removed the temp file + const tmpFiles = fs.readdirSync(regDir).filter((f) => f.startsWith("sandboxes.json.tmp.")); + expect(tmpFiles).toHaveLength(0); + }); +}); + +describe("advisory file locking", () => { + const lockDir = regFile + ".lock"; + const ownerFile = path.join(lockDir, "owner"); + + beforeEach(() => { + if (fs.existsSync(regFile)) fs.unlinkSync(regFile); + fs.rmSync(lockDir, { recursive: true, force: true }); + }); + + it("acquireLock creates lock directory with owner file and releaseLock removes both", () => { + registry.acquireLock(); + expect(fs.existsSync(lockDir)).toBe(true); + expect(fs.existsSync(ownerFile)).toBe(true); + expect(fs.readFileSync(ownerFile, "utf-8").trim()).toBe(String(process.pid)); + registry.releaseLock(); + expect(fs.existsSync(lockDir)).toBe(false); + }); + + it("withLock releases lock even when callback throws", () => { + expect(() => { + registry.withLock(() => { + expect(fs.existsSync(lockDir)).toBe(true); + throw new Error("intentional"); + }); + }).toThrow("intentional"); + expect(fs.existsSync(lockDir)).toBe(false); + }); + + it("acquireLock cleans up lock dir when owner file write fails", () => { + const origWrite = fs.writeFileSync; + let firstCall = true; + fs.writeFileSync = (...args) => { + // Fail only the first writeFileSync targeting the owner tmp file + if (String(args[0]).includes("owner.tmp.") && firstCall) { + firstCall = false; + throw Object.assign(new Error("ENOSPC"), { code: "ENOSPC" }); + } + return origWrite.apply(fs, args); + }; + try { + // First attempt should throw, but no stale lock dir left behind + expect(() => registry.acquireLock()).toThrow("ENOSPC"); + expect(fs.existsSync(lockDir)).toBe(false); + } finally { + fs.writeFileSync = origWrite; + } + }); + + it("acquireLock removes stale lock owned by dead process", () => { + // Create a lock with a PID that doesn't exist (99999999) + fs.mkdirSync(lockDir, { recursive: true }); + fs.writeFileSync(ownerFile, "99999999", { mode: 0o600 }); + + // Should succeed by detecting the dead owner and removing the stale lock + registry.acquireLock(); + expect(fs.existsSync(lockDir)).toBe(true); + expect(fs.readFileSync(ownerFile, "utf-8").trim()).toBe(String(process.pid)); + registry.releaseLock(); + }); + + it("mutating operations acquire and release the lock", () => { + const mkdirCalls = []; + const rmCalls = []; + const origMkdir = fs.mkdirSync; + const origRm = fs.rmSync; + fs.mkdirSync = (...args) => { + if (args[0] === lockDir) mkdirCalls.push(args[0]); + return origMkdir.apply(fs, args); + }; + fs.rmSync = (...args) => { + if (args[0] === lockDir) rmCalls.push(args[0]); + return origRm.apply(fs, args); + }; + try { + registry.registerSandbox({ name: "lock-test" }); + } finally { + fs.mkdirSync = origMkdir; + fs.rmSync = origRm; + } + expect(mkdirCalls.length).toBeGreaterThanOrEqual(1); + expect(rmCalls.length).toBeGreaterThanOrEqual(1); + expect(registry.getSandbox("lock-test").name).toBe("lock-test"); + }); + + it("concurrent writers do not corrupt the registry", () => { + const { spawnSync } = require("child_process"); + const registryPath = path.resolve( + path.join(import.meta.dirname, "..", "bin", "lib", "registry.js"), + ); + const homeDir = path.dirname(path.dirname(regFile)); + // Script that spawns 4 workers in parallel, each writing 5 sandboxes + const orchestrator = ` + const { spawn } = require("child_process"); + const workerScript = \` + process.env.HOME = ${JSON.stringify(homeDir)}; + const reg = require(${JSON.stringify(registryPath)}); + const id = process.argv[1]; + for (let i = 0; i < 5; i++) { + reg.registerSandbox({ name: id + "-" + i, model: "m" }); + } + \`; + const workers = []; + for (let w = 0; w < 4; w++) { + workers.push(spawn(process.execPath, ["-e", workerScript, "w" + w])); + } + let exitCount = 0; + let allOk = true; + for (const child of workers) { + child.on("exit", (code) => { + if (code !== 0) allOk = false; + exitCount++; + if (exitCount === workers.length) { + process.exit(allOk ? 0 : 1); + } + }); + } + `; + const result = spawnSync(process.execPath, ["-e", orchestrator], { + encoding: "utf-8", + timeout: 30_000, + }); + expect(result.status, result.stderr).toBe(0); + // All 20 sandboxes (4 workers × 5 each) must be present + const { sandboxes } = registry.listSandboxes(); + expect(sandboxes.length).toBe(20); + }); +}); diff --git a/test/resolve-openshell.test.js b/test/resolve-openshell.test.js new file mode 100644 index 000000000..7e2df8e1a --- /dev/null +++ b/test/resolve-openshell.test.js @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { resolveOpenshell } from "../bin/lib/resolve-openshell"; + +describe("resolveOpenshell", () => { + it("returns an absolute command -v result immediately", () => { + expect(resolveOpenshell({ commandVResult: "/usr/local/bin/openshell" })).toBe( + "/usr/local/bin/openshell", + ); + }); + + it("ignores non-absolute command -v output and falls back to known locations", () => { + expect( + resolveOpenshell({ + home: "/tmp/test-home", + commandVResult: "openshell", + checkExecutable: (candidate) => candidate === "/usr/local/bin/openshell", + }), + ).toBe("/usr/local/bin/openshell"); + }); + + it("prefers the home-local fallback before system paths", () => { + expect( + resolveOpenshell({ + home: "/tmp/test-home", + commandVResult: "", + checkExecutable: (candidate) => candidate === "/tmp/test-home/.local/bin/openshell", + }), + ).toBe("/tmp/test-home/.local/bin/openshell"); + }); + + it("skips invalid home values when checking fallback candidates", () => { + expect( + resolveOpenshell({ + home: "relative-home", + commandVResult: null, + checkExecutable: (candidate) => candidate === "/usr/bin/openshell", + }), + ).toBe("/usr/bin/openshell"); + }); + + it("returns null when no resolved path is executable", () => { + expect( + resolveOpenshell({ + home: "/tmp/test-home", + commandVResult: "", + checkExecutable: () => false, + }), + ).toBe(null); + }); +}); diff --git a/test/runner.test.js b/test/runner.test.js index 7bc561738..53885210f 100644 --- a/test/runner.test.js +++ b/test/runner.test.js @@ -6,7 +6,7 @@ import childProcess from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { runCapture } from "../bin/lib/runner"; @@ -39,7 +39,7 @@ describe("runner helpers", () => { // @ts-expect-error — intentional partial mock for testing childProcess.spawnSync = (...args) => { calls.push(args); - return { status: 0 }; + return { status: 0, stdout: "", stderr: "" }; }; try { @@ -53,8 +53,8 @@ describe("runner helpers", () => { } expect(calls).toHaveLength(2); - expect(calls[0][2].stdio).toEqual(["ignore", "inherit", "inherit"]); - expect(calls[1][2].stdio).toBe("inherit"); + expect(calls[0][2].stdio).toEqual(["ignore", "pipe", "pipe"]); + expect(calls[1][2].stdio).toEqual(["inherit", "pipe", "pipe"]); }); }); @@ -63,7 +63,7 @@ describe("runner env merging", () => { const originalGateway = process.env.OPENSHELL_GATEWAY; process.env.OPENSHELL_GATEWAY = "nemoclaw"; try { - const output = runCapture("printf '%s %s' \"$OPENSHELL_GATEWAY\" \"$OPENAI_API_KEY\"", { + const output = runCapture('printf \'%s %s\' "$OPENSHELL_GATEWAY" "$OPENAI_API_KEY"', { env: { OPENAI_API_KEY: "sk-test-secret" }, }); expect(output).toBe("nemoclaw sk-test-secret"); @@ -83,14 +83,16 @@ describe("runner env merging", () => { // @ts-expect-error — intentional partial mock for testing childProcess.spawnSync = (...args) => { calls.push(args); - return { status: 0 }; + return { status: 0, stdout: "", stderr: "" }; }; try { delete require.cache[require.resolve(runnerPath)]; const { run } = require(runnerPath); process.env.PATH = "/usr/local/bin:/usr/bin"; - run("echo test", { env: { OPENSHELL_CLUSTER_IMAGE: "ghcr.io/nvidia/openshell/cluster:0.0.12" } }); + run("echo test", { + env: { OPENSHELL_CLUSTER_IMAGE: "ghcr.io/nvidia/openshell/cluster:0.0.12" }, + }); } finally { if (originalPath === undefined) { delete process.env.PATH; @@ -168,9 +170,238 @@ describe("validateName", () => { }); }); +describe("redact", () => { + it("masks NVIDIA API keys", () => { + const { redact } = require(runnerPath); + expect(redact("key is nvapi-abc123XYZ_def456")).toBe("key is nvap******************"); + }); + + it("masks NVCF keys", () => { + const { redact } = require(runnerPath); + expect(redact("nvcf-abcdef1234567890")).toBe("nvcf*****************"); + }); + + it("masks bearer tokens", () => { + const { redact } = require(runnerPath); + expect(redact("Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.payload")).toBe( + "Authorization: Bearer eyJh********************", + ); + }); + + it("masks key assignments in commands", () => { + const { redact } = require(runnerPath); + expect(redact("export NVIDIA_API_KEY=nvapi-realkey12345")).toContain("nvap"); + expect(redact("export NVIDIA_API_KEY=nvapi-realkey12345")).not.toContain("realkey12345"); + }); + + it("masks variables ending in _KEY", () => { + const { redact } = require(runnerPath); + const output = redact('export SERVICE_KEY="supersecretvalue12345"'); + expect(output).not.toContain("supersecretvalue12345"); + expect(output).toContain('export SERVICE_KEY="supe'); + }); + + it("masks bare GitHub personal access tokens", () => { + const { redact } = require(runnerPath); + const output = redact("token ghp_abcdefghijklmnopqrstuvwxyz1234567890"); + expect(output).toContain("ghp_"); + expect(output).not.toContain("abcdefghijklmnopqrstuvwxyz1234567890"); + }); + + it("masks bearer tokens case-insensitively", () => { + const { redact } = require(runnerPath); + expect(redact("authorization: bearer someBearerToken")).toContain("some****"); + expect(redact("authorization: bearer someBearerToken")).not.toContain("someBearerToken"); + expect(redact("AUTHORIZATION: BEARER someBearerToken")).toContain("some****"); + expect(redact("AUTHORIZATION: BEARER someBearerToken")).not.toContain("someBearerToken"); + }); + + it("masks bearer tokens with repeated spacing", () => { + const { redact } = require(runnerPath); + const output = redact("Authorization: Bearer someBearerToken"); + expect(output).toContain("some****"); + expect(output).not.toContain("someBearerToken"); + }); + + it("masks quoted assignment values", () => { + const { redact } = require(runnerPath); + const output = redact('API_KEY="secret123abc"'); + expect(output).not.toContain("secret123abc"); + expect(output).toContain('API_KEY="sec'); + }); + + it("masks multiple secrets in one string", () => { + const { redact } = require(runnerPath); + const output = redact("nvapi-firstkey12345 nvapi-secondkey67890"); + expect(output).not.toContain("firstkey12345"); + expect(output).not.toContain("secondkey67890"); + expect(output).toContain("nvap"); + expect(output).toContain(" "); + }); + + it("masks URL credentials and auth query parameters", () => { + const { redact } = require(runnerPath); + const output = redact( + "https://alice:secret@example.com/v1/models?auth=abc123456789&sig=def987654321&keep=yes", + ); + expect(output).toBe("https://alice:****@example.com/v1/models?auth=****&sig=****&keep=yes"); + }); + + it("masks auth-style query parameters case-insensitively", () => { + const { redact } = require(runnerPath); + const output = redact("https://example.com?Signature=secret123456&AUTH=anothersecret123"); + expect(output).toBe("https://example.com/?Signature=****&AUTH=****"); + }); + + it("leaves non-secret strings untouched", () => { + const { redact } = require(runnerPath); + expect(redact("docker run --name my-sandbox")).toBe("docker run --name my-sandbox"); + expect(redact("openshell sandbox list")).toBe("openshell sandbox list"); + }); + + it("handles non-string input gracefully", () => { + const { redact } = require(runnerPath); + expect(redact(null)).toBe(null); + expect(redact(undefined)).toBe(undefined); + expect(redact(42)).toBe(42); + }); +}); + describe("regression guards", () => { + it("runCapture redacts secrets before rethrowing errors", () => { + const originalExecSync = childProcess.execSync; + childProcess.execSync = () => { + throw new Error( + 'command failed: export SERVICE_KEY="supersecretvalue12345" ghp_abcdefghijklmnopqrstuvwxyz1234567890', + ); + }; + + try { + delete require.cache[require.resolve(runnerPath)]; + const { runCapture } = require(runnerPath); + + let error; + try { + runCapture("echo nope"); + } catch (err) { + error = err; + } + + expect(error).toBeInstanceOf(Error); + expect(error.message).toContain("ghp_"); + expect(error.message).not.toContain("supersecretvalue12345"); + expect(error.message).not.toContain("abcdefghijklmnopqrstuvwxyz1234567890"); + } finally { + childProcess.execSync = originalExecSync; + delete require.cache[require.resolve(runnerPath)]; + } + }); + + it("runCapture redacts execSync error cmd/output fields", () => { + const originalExecSync = childProcess.execSync; + childProcess.execSync = () => { + const err = /** @type {any} */ (new Error("command failed")); + err.cmd = "echo nvapi-aaaabbbbcccc1111 && echo ghp_abcdefghijklmnopqrstuvwxyz123456"; + err.output = ["stdout: nvapi-aaaabbbbcccc1111", "stderr: PASSWORD=secret123456"]; + throw err; + }; + + try { + delete require.cache[require.resolve(runnerPath)]; + const { runCapture } = require(runnerPath); + + let error; + try { + runCapture("echo nope"); + } catch (err) { + error = /** @type {any} */ (err); + } + + expect(error).toBeDefined(); + expect(error).toBeInstanceOf(Error); + expect(error.cmd).not.toContain("nvapi-aaaabbbbcccc1111"); + expect(error.cmd).not.toContain("ghp_abcdefghijklmnopqrstuvwxyz123456"); + expect(Array.isArray(error.output)).toBe(true); + expect(error.output[0]).not.toContain("nvapi-aaaabbbbcccc1111"); + expect(error.output[1]).not.toContain("secret123456"); + expect(error.output[0]).toContain("****"); + expect(error.output[1]).toContain("****"); + } finally { + childProcess.execSync = originalExecSync; + delete require.cache[require.resolve(runnerPath)]; + } + }); + + it("run redacts captured child output before printing on failure", () => { + const originalSpawnSync = childProcess.spawnSync; + const originalExit = process.exit; + const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + // @ts-expect-error — intentional partial mock for testing + childProcess.spawnSync = () => ({ + status: 1, + stdout: "token ghp_abcdefghijklmnopqrstuvwxyz1234567890\n", + stderr: 'export SERVICE_KEY="supersecretvalue12345"\n', + }); + process.exit = (code) => { + throw new Error(`exit:${code}`); + }; + + try { + delete require.cache[require.resolve(runnerPath)]; + const { run } = require(runnerPath); + expect(() => run("echo fail")).toThrow("exit:1"); + expect(stdoutSpy).toHaveBeenCalledWith("token ghp_********************\n"); + expect(stderrSpy).toHaveBeenCalledWith('export SERVICE_KEY="supe*****************"\n'); + expect(errorSpy).toHaveBeenCalledWith(" Command failed (exit 1): echo fail"); + } finally { + childProcess.spawnSync = originalSpawnSync; + process.exit = originalExit; + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + errorSpy.mockRestore(); + delete require.cache[require.resolve(runnerPath)]; + } + }); + + it("runInteractive keeps stdin inherited while redacting captured output", () => { + const originalSpawnSync = childProcess.spawnSync; + const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + const calls = []; + + // @ts-expect-error — intentional partial mock for testing + childProcess.spawnSync = (...args) => { + calls.push(args); + return { + status: 0, + stdout: "visit https://alice:secret@example.com/?token=abc123456789\n", // gitleaks:allow + stderr: "", + }; + }; + + try { + delete require.cache[require.resolve(runnerPath)]; + const { runInteractive } = require(runnerPath); + runInteractive("echo interactive"); + expect(calls[0][2].stdio).toEqual(["inherit", "pipe", "pipe"]); + expect(stdoutSpy).toHaveBeenCalledWith("visit https://alice:****@example.com/?token=****\n"); + expect(stderrSpy).not.toHaveBeenCalled(); + } finally { + childProcess.spawnSync = originalSpawnSync; + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + delete require.cache[require.resolve(runnerPath)]; + } + }); + it("nemoclaw.js does not use execSync", () => { - const src = fs.readFileSync(path.join(import.meta.dirname, "..", "bin", "nemoclaw.js"), "utf-8"); + const src = fs.readFileSync( + path.join(import.meta.dirname, "..", "bin", "nemoclaw.js"), + "utf-8", + ); const lines = src.split("\n"); for (let i = 0; i < lines.length; i += 1) { if (lines[i].includes("execSync") && !lines[i].includes("execFileSync")) { @@ -205,15 +436,19 @@ describe("regression guards", () => { const canaryDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-canary-")); const canary = path.join(canaryDir, "executed"); try { - const result = spawnSync("node", [ - path.join(import.meta.dirname, "..", "bin", "nemoclaw.js"), - `test; touch ${canary}`, - "connect", - ], { - encoding: "utf-8", - timeout: 10000, - cwd: path.join(import.meta.dirname, ".."), - }); + const result = spawnSync( + "node", + [ + path.join(import.meta.dirname, "..", "bin", "nemoclaw.js"), + `test; touch ${canary}`, + "connect", + ], + { + encoding: "utf-8", + timeout: 10000, + cwd: path.join(import.meta.dirname, ".."), + }, + ); expect(result.status).not.toBe(0); expect(fs.existsSync(canary)).toBe(false); } finally { @@ -222,7 +457,10 @@ describe("regression guards", () => { }); it("telegram bridge validates SANDBOX_NAME on startup", () => { - const src = fs.readFileSync(path.join(import.meta.dirname, "..", "scripts", "telegram-bridge.js"), "utf-8"); + const src = fs.readFileSync( + path.join(import.meta.dirname, "..", "scripts", "telegram-bridge.js"), + "utf-8", + ); expect(src.includes("validateName(SANDBOX")).toBeTruthy(); expect(src.includes("execSync")).toBeFalsy(); }); @@ -230,7 +468,10 @@ describe("regression guards", () => { describe("credential exposure guards (#429)", () => { it("onboard createSandbox does not pass NVIDIA_API_KEY to sandbox env", () => { const fs = require("fs"); - const src = fs.readFileSync(path.join(import.meta.dirname, "..", "bin", "lib", "onboard.js"), "utf-8"); + const src = fs.readFileSync( + path.join(import.meta.dirname, "..", "bin", "lib", "onboard.js"), + "utf-8", + ); // Find the envArgs block in createSandbox — it should not contain NVIDIA_API_KEY const envArgsMatch = src.match(/const envArgs = \[[\s\S]*?\];/); expect(envArgsMatch).toBeTruthy(); @@ -239,43 +480,23 @@ describe("regression guards", () => { it("onboard clears NVIDIA_API_KEY from process.env after setupInference", () => { const fs = require("fs"); - const src = fs.readFileSync(path.join(import.meta.dirname, "..", "bin", "lib", "onboard.js"), "utf-8"); + const src = fs.readFileSync( + path.join(import.meta.dirname, "..", "bin", "lib", "onboard.js"), + "utf-8", + ); expect(src.includes("delete process.env.NVIDIA_API_KEY")).toBeTruthy(); }); - it("setup.sh uses env-name-only form for nvidia-nim credential", () => { - const fs = require("fs"); - const src = fs.readFileSync(path.join(import.meta.dirname, "..", "scripts", "setup.sh"), "utf-8"); - // Should use "NVIDIA_API_KEY" (name only), not "NVIDIA_API_KEY=$NVIDIA_API_KEY" (value) - const lines = src.split("\n"); - for (const line of lines) { - if (line.includes("upsert_provider") || line.includes("--credential")) continue; - if (line.trim().startsWith("#")) continue; - // Check credential argument lines passed to upsert_provider - if (line.includes('"NVIDIA_API_KEY=')) { - // Allow "NVIDIA_API_KEY" alone but not "NVIDIA_API_KEY=$..." - expect(line.includes("NVIDIA_API_KEY=$")).toBe(false); - } - } - }); - - it("setup.sh does not pass NVIDIA_API_KEY in sandbox create env args", () => { - const fs = require("fs"); - const src = fs.readFileSync(path.join(import.meta.dirname, "..", "scripts", "setup.sh"), "utf-8"); - // Find sandbox create command — should not have env NVIDIA_API_KEY - const createLines = src.split("\n").filter((l) => l.includes("sandbox create")); - for (const line of createLines) { - expect(line.includes("NVIDIA_API_KEY")).toBe(false); - } - }); - it("setupSpark does not pass NVIDIA_API_KEY to sudo", () => { const fs = require("fs"); - const src = fs.readFileSync(path.join(import.meta.dirname, "..", "bin", "nemoclaw.js"), "utf-8"); - // Find the run() call inside setupSpark — it should not contain the key - const sparkLines = src.split("\n").filter( - (l) => l.includes("setup-spark") && l.includes("run(") + const src = fs.readFileSync( + path.join(import.meta.dirname, "..", "bin", "nemoclaw.js"), + "utf-8", ); + // Find the run() call inside setupSpark — it should not contain the key + const sparkLines = src + .split("\n") + .filter((l) => l.includes("setup-spark") && l.includes("run(")); for (const line of sparkLines) { expect(line.includes("NVIDIA_API_KEY")).toBe(false); } @@ -283,15 +504,160 @@ describe("regression guards", () => { it("walkthrough.sh does not embed NVIDIA_API_KEY in tmux or sandbox commands", () => { const fs = require("fs"); - const src = fs.readFileSync(path.join(import.meta.dirname, "..", "scripts", "walkthrough.sh"), "utf-8"); - // Check only executable lines (tmux spawn, openshell connect) — not comments/docs - const cmdLines = src.split("\n").filter( - (l) => !l.trim().startsWith("#") && !l.trim().startsWith("echo") && - (l.includes("tmux") || l.includes("openshell sandbox connect")) + const src = fs.readFileSync( + path.join(import.meta.dirname, "..", "scripts", "walkthrough.sh"), + "utf-8", ); + // Check only executable lines (tmux spawn, openshell connect) — not comments/docs + const cmdLines = src + .split("\n") + .filter( + (l) => + !l.trim().startsWith("#") && + !l.trim().startsWith("echo") && + (l.includes("tmux") || l.includes("openshell sandbox connect")), + ); for (const line of cmdLines) { expect(line.includes("NVIDIA_API_KEY")).toBe(false); } }); + + it("install-openshell.sh verifies OpenShell binary checksum after download", () => { + const src = fs.readFileSync( + path.join(import.meta.dirname, "..", "scripts", "install-openshell.sh"), + "utf-8", + ); + expect(src).toContain("openshell-checksums-sha256.txt"); + expect(src).toContain("shasum -a 256 -c"); + }); + + it("install-openshell.sh falls back to curl when gh fails (#1318)", () => { + const src = fs.readFileSync( + path.join(import.meta.dirname, "..", "scripts", "install-openshell.sh"), + "utf-8", + ); + expect(src).toContain("download_with_curl"); + const ghBlock = src.slice(src.indexOf("command -v gh")); + expect(ghBlock).toContain("2>/dev/null"); + expect(ghBlock).toContain("falling back to curl"); + expect(ghBlock).toContain("download_with_curl"); + }); + + it("install-openshell.sh gh-absent path uses curl directly", () => { + const src = fs.readFileSync( + path.join(import.meta.dirname, "..", "scripts", "install-openshell.sh"), + "utf-8", + ); + expect(src).toContain("download_with_curl"); + const ghCheck = src.indexOf("command -v gh"); + const elseBlock = src.indexOf("\nelse\n", ghCheck); + const finalFi = src.indexOf("\nfi\n", elseBlock); + expect(ghCheck).toBeGreaterThan(-1); + expect(elseBlock).toBeGreaterThan(ghCheck); + expect(finalFi).toBeGreaterThan(elseBlock); + const fallthrough = src.slice(elseBlock, finalFi); + expect(fallthrough).toContain("download_with_curl"); + expect(fallthrough).not.toContain("gh release"); + }); + + it("install-openshell.sh gh-present-but-fails path falls back to curl", () => { + const scriptPath = path.join(import.meta.dirname, "..", "scripts", "install-openshell.sh"); + const tmpBin = fs.mkdtempSync(path.join(os.tmpdir(), "gh-stub-")); + const ghStub = path.join(tmpBin, "gh"); + fs.writeFileSync(ghStub, "#!/bin/sh\nexit 4\n"); + fs.chmodSync(ghStub, 0o755); + + const stub = ` + #!/usr/bin/env bash + openshell() { echo "openshell 0.0.1"; } + export -f openshell + export PATH="${tmpBin}:/usr/bin:/bin" + curl() { echo "CURL_FALLBACK $*"; return 0; } + export -f curl + shasum() { echo "checksum OK"; return 0; } + export -f shasum + tar() { return 0; }; export -f tar + install() { return 0; }; export -f install + source "${scriptPath}" + `; + try { + const result = spawnSync("bash", ["-c", stub], { + encoding: "utf-8", + timeout: 5000, + }); + const out = (result.stdout || "") + (result.stderr || ""); + expect(out).toContain("falling back to curl"); + expect(out).toContain("CURL_FALLBACK"); + } finally { + fs.rmSync(tmpBin, { recursive: true, force: true }); + } + }); + }); + + describe("curl-pipe-to-shell guards (#574, #583)", () => { + // Strip comment lines, then join line continuations so multiline + // curl ... |\n bash patterns are caught by the single-line regex. + const stripComments = (src, commentPrefix) => + src + .split("\n") + .filter((l) => !l.trim().startsWith(commentPrefix)) + .join("\n"); + + const joinContinuations = (src) => src.replace(/\\\n\s*/g, " "); + + const collapseMultilinePipes = (src) => src.replace(/\|\s*\n\s*/g, "| "); + + const normalize = (src, commentPrefix) => + collapseMultilinePipes(joinContinuations(stripComments(src, commentPrefix))); + + const shellViolationRe = /curl\s[^|]*\|\s*(sh|bash|sudo\s+(-\S+\s+)*(sh|bash))\b/; + const jsViolationRe = /curl.*\|\s*(sh|bash|sudo\s+(-\S+\s+)*(sh|bash))\b/; + + const findShellViolations = (src) => { + const normalized = normalize(src, "#"); + return normalized.split("\n").filter((line) => { + const t = line.trim(); + if (t.startsWith("printf") || t.startsWith("echo")) return false; + return shellViolationRe.test(t); + }); + }; + + const findJsViolations = (src) => { + const normalized = normalize(src, "//"); + return normalized.split("\n").filter((line) => { + const t = line.trim(); + if (t.startsWith("*")) return false; + return jsViolationRe.test(t); + }); + }; + + it("install.sh does not pipe curl to shell", () => { + const src = fs.readFileSync(path.join(import.meta.dirname, "..", "install.sh"), "utf-8"); + expect(findShellViolations(src)).toEqual([]); + }); + + it("scripts/install.sh does not pipe curl to shell", () => { + const src = fs.readFileSync( + path.join(import.meta.dirname, "..", "scripts", "install.sh"), + "utf-8", + ); + expect(findShellViolations(src)).toEqual([]); + }); + + it("scripts/brev-setup.sh does not pipe curl to shell", () => { + const src = fs.readFileSync( + path.join(import.meta.dirname, "..", "scripts", "brev-setup.sh"), + "utf-8", + ); + expect(findShellViolations(src)).toEqual([]); + }); + + it("bin/nemoclaw.js does not pipe curl to shell", () => { + const src = fs.readFileSync( + path.join(import.meta.dirname, "..", "bin", "nemoclaw.js"), + "utf-8", + ); + expect(findJsViolations(src)).toEqual([]); + }); }); }); diff --git a/test/runtime-shell.test.js b/test/runtime-shell.test.js index 2e5702f22..4be87381c 100644 --- a/test/runtime-shell.test.js +++ b/test/runtime-shell.test.js @@ -58,7 +58,9 @@ describe("shell runtime helpers", () => { }); it("classifies a Docker Desktop DOCKER_HOST correctly", () => { - const result = runShell(`source "${RUNTIME_SH}"; docker_host_runtime "unix:///Users/test/.docker/run/docker.sock"`); + const result = runShell( + `source "${RUNTIME_SH}"; docker_host_runtime "unix:///Users/test/.docker/run/docker.sock"`, + ); expect(result.status).toBe(0); expect(result.stdout.trim()).toBe("docker-desktop"); @@ -98,7 +100,9 @@ describe("shell runtime helpers", () => { }); it("detects podman from docker info output", () => { - const result = runShell(`source "${RUNTIME_SH}"; infer_container_runtime_from_info "podman version 5.4.1"`); + const result = runShell( + `source "${RUNTIME_SH}"; infer_container_runtime_from_info "podman version 5.4.1"`, + ); expect(result.status).toBe(0); expect(result.stdout.trim()).toBe("podman"); }); diff --git a/test/security-binaries-restriction.test.js b/test/security-binaries-restriction.test.js index 6873e0bb9..648fc4689 100644 --- a/test/security-binaries-restriction.test.js +++ b/test/security-binaries-restriction.test.js @@ -5,8 +5,20 @@ import { describe, it, expect } from "vitest"; import fs from "node:fs"; import path from "node:path"; -const BASELINE = path.join(import.meta.dirname, "..", "nemoclaw-blueprint", "policies", "openclaw-sandbox.yaml"); -const PRESETS_DIR = path.join(import.meta.dirname, "..", "nemoclaw-blueprint", "policies", "presets"); +const BASELINE = path.join( + import.meta.dirname, + "..", + "nemoclaw-blueprint", + "policies", + "openclaw-sandbox.yaml", +); +const PRESETS_DIR = path.join( + import.meta.dirname, + "..", + "nemoclaw-blueprint", + "policies", + "presets", +); describe("binaries restriction: baseline policy", () => { it("every network_policies entry has a binaries section", () => { @@ -20,7 +32,10 @@ describe("binaries restriction: baseline policy", () => { for (let i = 0; i < lines.length; i++) { const line = lines[i]; - if (/^network_policies:/.test(line)) { inNetworkPolicies = true; continue; } + if (/^network_policies:/.test(line)) { + inNetworkPolicies = true; + continue; + } if (inNetworkPolicies && /^\S/.test(line) && line.trim() !== "") { if (currentBlock) blocks.push(currentBlock); currentBlock = null; @@ -40,15 +55,15 @@ describe("binaries restriction: baseline policy", () => { expect(blocks.length).toBeGreaterThan(0); - const violators = blocks.filter(b => !b.lines.some(l => /^\s+binaries:/.test(l))); + const violators = blocks.filter((b) => !b.lines.some((l) => /^\s+binaries:/.test(l))); - expect(violators.map(b => b.name)).toEqual([]); + expect(violators.map((b) => b.name)).toEqual([]); }); }); describe("binaries restriction: policy presets", () => { it("every preset YAML has a binaries section", () => { - const presets = fs.readdirSync(PRESETS_DIR).filter(f => f.endsWith(".yaml")); + const presets = fs.readdirSync(PRESETS_DIR).filter((f) => f.endsWith(".yaml")); expect(presets.length).toBeGreaterThan(0); const missing = []; diff --git a/test/security-c2-dockerfile-injection.test.js b/test/security-c2-dockerfile-injection.test.js index 285df61bd..5689ce8d5 100644 --- a/test/security-c2-dockerfile-injection.test.js +++ b/test/security-c2-dockerfile-injection.test.js @@ -76,7 +76,11 @@ describe("C-2 PoC: vulnerable pattern (ARG interpolation into python3 -c)", () = expect(fs.existsSync(canary)).toBeTruthy(); expect(fs.readFileSync(canary, "utf-8")).toBe("PWNED"); } finally { - try { fs.unlinkSync(canary); } catch { /* cleanup */ } + try { + fs.unlinkSync(canary); + } catch { + /* cleanup */ + } } }); }); @@ -106,7 +110,11 @@ describe("C-2 fix: env var pattern (os.environ) is safe", () => { expect(result.status).toBe(0); expect(fs.existsSync(canary)).toBe(false); } finally { - try { fs.unlinkSync(canary); } catch { /* cleanup */ } + try { + fs.unlinkSync(canary); + } catch { + /* cleanup */ + } } }); @@ -138,8 +146,8 @@ describe("C-2 regression: Dockerfile must not interpolate build-args into Python if (inPythonRunBlock && vulnerablePattern.test(line)) { expect.unreachable( `Dockerfile:${i + 1} interpolates CHAT_UI_URL into a Python string literal.\n` + - ` Line: ${line.trim()}\n` + - ` Fix: use os.environ['CHAT_UI_URL'] instead.` + ` Line: ${line.trim()}\n` + + ` Fix: use os.environ['CHAT_UI_URL'] instead.`, ); } if (inPythonRunBlock && !/\\\s*$/.test(line)) { @@ -161,8 +169,8 @@ describe("C-2 regression: Dockerfile must not interpolate build-args into Python if (inPythonRunBlock && vulnerablePattern.test(line)) { expect.unreachable( `Dockerfile:${i + 1} interpolates NEMOCLAW_MODEL into a Python string literal.\n` + - ` Line: ${line.trim()}\n` + - ` Fix: use os.environ['NEMOCLAW_MODEL'] instead.` + ` Line: ${line.trim()}\n` + + ` Fix: use os.environ['NEMOCLAW_MODEL'] instead.`, ); } if (inPythonRunBlock && !/\\\s*$/.test(line)) { @@ -291,3 +299,71 @@ describe("C-2 regression: Dockerfile must not interpolate build-args into Python expect(hasEnvRead).toBeTruthy(); }); }); + +// ═══════════════════════════════════════════════════════════════════ +// 4. Gateway auth hardening — no hardcoded insecure defaults (#117) +// ═══════════════════════════════════════════════════════════════════ +describe("Gateway auth hardening: Dockerfile must not hardcode insecure auth defaults", () => { + it("dangerouslyDisableDeviceAuth is not hardcoded to True", () => { + const src = fs.readFileSync(DOCKERFILE, "utf-8"); + // Must not contain a literal `'dangerouslyDisableDeviceAuth': True` + expect(src).not.toMatch(/'dangerouslyDisableDeviceAuth':\s*True/); + }); + + it("allowInsecureAuth is not hardcoded to True", () => { + const src = fs.readFileSync(DOCKERFILE, "utf-8"); + // Must not contain a literal `'allowInsecureAuth': True` + expect(src).not.toMatch(/'allowInsecureAuth':\s*True/); + }); + + it("dangerouslyDisableDeviceAuth is derived from NEMOCLAW_DISABLE_DEVICE_AUTH env var", () => { + const src = fs.readFileSync(DOCKERFILE, "utf-8"); + // The Python config generation must read the env var + expect(src).toMatch(/os\.environ\.get\(['"]NEMOCLAW_DISABLE_DEVICE_AUTH['"]/); + // And use the derived variable in the config dict + expect(src).toMatch(/'dangerouslyDisableDeviceAuth':\s*disable_device_auth/); + }); + + it("allowInsecureAuth is derived from URL scheme (explicit http allowlist)", () => { + const src = fs.readFileSync(DOCKERFILE, "utf-8"); + // Must use explicit 'http' allowlist — not `!= 'https'` which would allow + // insecure auth for malformed or unknown schemes (CodeRabbit review on #123) + expect(src).toMatch(/allow_insecure\s*=\s*parsed\.scheme\s*==\s*'http'/); + expect(src).not.toMatch(/allow_insecure\s*=\s*parsed\.scheme\s*!=\s*'https'/); + // And use the derived variable in the config dict + expect(src).toMatch(/'allowInsecureAuth':\s*allow_insecure/); + }); + + it("NEMOCLAW_DISABLE_DEVICE_AUTH defaults to '0' (secure by default)", () => { + const src = fs.readFileSync(DOCKERFILE, "utf-8"); + expect(src).toMatch(/ARG\s+NEMOCLAW_DISABLE_DEVICE_AUTH=0/); + }); + + it("NEMOCLAW_DISABLE_DEVICE_AUTH is promoted to ENV before the Python RUN layer", () => { + const src = fs.readFileSync(DOCKERFILE, "utf-8"); + const lines = src.split("\n"); + let promoted = false; + let inEnvBlock = false; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (/^\s*FROM\b/.test(line)) { + promoted = false; + inEnvBlock = false; + } + if (/^\s*ENV\b/.test(line)) { + inEnvBlock = true; + } + if (inEnvBlock && /NEMOCLAW_DISABLE_DEVICE_AUTH[=\s]/.test(line)) { + promoted = true; + } + if (inEnvBlock && !/\\\s*$/.test(line)) { + inEnvBlock = false; + } + if (/^\s*RUN\b.*python3\s+-c\b/.test(line)) { + expect(promoted).toBeTruthy(); + return; + } + } + expect(promoted).toBeTruthy(); + }); +}); diff --git a/test/security-c4-manifest-traversal.test.js b/test/security-c4-manifest-traversal.test.js index 21996bb71..74894bbbd 100644 --- a/test/security-c4-manifest-traversal.test.js +++ b/test/security-c4-manifest-traversal.test.js @@ -62,10 +62,7 @@ function buildSnapshotDir(parentDir, manifest) { path.join(snapshotDir, "config", "openclaw.json"), JSON.stringify({ model: "attacker-model" }), ); - fs.writeFileSync( - path.join(snapshotDir, "snapshot.json"), - JSON.stringify(manifest, null, 2), - ); + fs.writeFileSync(path.join(snapshotDir, "snapshot.json"), JSON.stringify(manifest, null, 2)); return snapshotDir; } @@ -74,9 +71,7 @@ function buildSnapshotDir(parentDir, manifest) { * Returns { result, errors, written }. */ function restoreVulnerable(snapshotDir) { - const manifest = JSON.parse( - fs.readFileSync(path.join(snapshotDir, "snapshot.json"), "utf-8"), - ); + const manifest = JSON.parse(fs.readFileSync(path.join(snapshotDir, "snapshot.json"), "utf-8")); const snapshotStateDir = path.join(snapshotDir, "openclaw"); const errors = []; let written = false; @@ -107,9 +102,7 @@ function restoreVulnerable(snapshotDir) { * @param {string} [trustedRoot] - trusted host root (defaults to os.homedir()) */ function restoreFixed(snapshotDir, trustedRoot) { - const manifest = JSON.parse( - fs.readFileSync(path.join(snapshotDir, "snapshot.json"), "utf-8"), - ); + const manifest = JSON.parse(fs.readFileSync(path.join(snapshotDir, "snapshot.json"), "utf-8")); const snapshotStateDir = path.join(snapshotDir, "openclaw"); const errors = []; let written = false; @@ -200,7 +193,9 @@ describe("C-4 PoC: vulnerable restoreSnapshotToHost allows path traversal", () = expect(result).toBeTruthy(); expect(written).toBeTruthy(); expect(fs.existsSync(path.join(traversalTarget, "sentinel.txt"))).toBeTruthy(); - expect(fs.readFileSync(path.join(traversalTarget, "sentinel.txt"), "utf-8")).toBe("attacker-controlled-content"); + expect(fs.readFileSync(path.join(traversalTarget, "sentinel.txt"), "utf-8")).toBe( + "attacker-controlled-content", + ); } finally { fs.rmSync(workDir, { recursive: true, force: true }); } @@ -443,7 +438,9 @@ describe("C-4 regression: migration-state.ts contains path validation", () => { it("restoreSnapshotToHost fails closed when hasExternalConfig is true with missing configPath", () => { const fnBody = getRestoreFnBody(); - expect(/manifest\.hasExternalConfig\b/.test(fnBody) && - /typeof\s+manifest\.configPath\s*!==\s*["']string["']/.test(fnBody)).toBeTruthy(); + expect( + /manifest\.hasExternalConfig\b/.test(fnBody) && + /typeof\s+manifest\.configPath\s*!==\s*["']string["']/.test(fnBody), + ).toBeTruthy(); }); }); diff --git a/test/security-method-wildcards.test.js b/test/security-method-wildcards.test.js new file mode 100644 index 000000000..17ead7346 --- /dev/null +++ b/test/security-method-wildcards.test.js @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; + +const BASELINE = path.join( + import.meta.dirname, + "..", + "nemoclaw-blueprint", + "policies", + "openclaw-sandbox.yaml", +); + +describe("method wildcards: baseline policy", () => { + it('no endpoint uses method: "*" wildcard', () => { + // method: "*" permits DELETE, PUT, PATCH which inference APIs do not + // require. All endpoints should use explicit method rules (GET, POST). + const yaml = fs.readFileSync(BASELINE, "utf-8"); + const lines = yaml.split("\n"); + const violations = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (/method:\s*["']\*["']/.test(line)) { + violations.push({ line: i + 1, content: line.trim() }); + } + } + + expect(violations).toEqual([]); + }); +}); diff --git a/test/service-env.test.js b/test/service-env.test.js index b3da31314..429e17a70 100644 --- a/test/service-env.test.js +++ b/test/service-env.test.js @@ -2,62 +2,120 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, it, expect } from "vitest"; -import { execSync } from "node:child_process"; +import { execSync, execFileSync } from "node:child_process"; +import { mkdtempSync, writeFileSync, unlinkSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { resolveOpenshell } from "../bin/lib/resolve-openshell"; +import { parseAllowedChatIds, isChatAllowed } from "../bin/lib/chat-filter.js"; describe("service environment", () => { + describe("start-services behavior", () => { + const scriptPath = join(import.meta.dirname, "../scripts/start-services.sh"); + + it("starts local-only services without NVIDIA_API_KEY", () => { + const workspace = mkdtempSync(join(tmpdir(), "nemoclaw-services-no-key-")); + const result = execFileSync("bash", [scriptPath], { + encoding: "utf-8", + env: { + ...process.env, + NVIDIA_API_KEY: "", + TELEGRAM_BOT_TOKEN: "", + SANDBOX_NAME: "test-box", + TMPDIR: workspace, + }, + }); + + expect(result).not.toContain("NVIDIA_API_KEY required"); + expect(result).toContain("TELEGRAM_BOT_TOKEN not set"); + expect(result).toContain("Telegram: not started (no token)"); + }); + + it("warns and skips Telegram bridge when token is set without NVIDIA_API_KEY", () => { + const workspace = mkdtempSync(join(tmpdir(), "nemoclaw-services-missing-key-")); + const result = execFileSync("bash", [scriptPath], { + encoding: "utf-8", + env: { + ...process.env, + NVIDIA_API_KEY: "", + TELEGRAM_BOT_TOKEN: "test-token", + SANDBOX_NAME: "test-box", + TMPDIR: workspace, + }, + }); + + expect(result).not.toContain("NVIDIA_API_KEY required"); + expect(result).toContain("NVIDIA_API_KEY not set"); + expect(result).toContain("Telegram: not started (no token)"); + }); + }); + describe("resolveOpenshell logic", () => { it("returns command -v result when absolute path", () => { expect(resolveOpenshell({ commandVResult: "/usr/bin/openshell" })).toBe("/usr/bin/openshell"); }); it("rejects non-absolute command -v result (alias)", () => { - expect( - resolveOpenshell({ commandVResult: "openshell", checkExecutable: () => false }) - ).toBe(null); + expect(resolveOpenshell({ commandVResult: "openshell", checkExecutable: () => false })).toBe( + null, + ); }); it("rejects alias definition from command -v", () => { expect( - resolveOpenshell({ commandVResult: "alias openshell='echo pwned'", checkExecutable: () => false }) + resolveOpenshell({ + commandVResult: "alias openshell='echo pwned'", + checkExecutable: () => false, + }), ).toBe(null); }); it("falls back to ~/.local/bin when command -v fails", () => { - expect(resolveOpenshell({ - commandVResult: null, - checkExecutable: (p) => p === "/fakehome/.local/bin/openshell", - home: "/fakehome", - })).toBe("/fakehome/.local/bin/openshell"); + expect( + resolveOpenshell({ + commandVResult: null, + checkExecutable: (p) => p === "/fakehome/.local/bin/openshell", + home: "/fakehome", + }), + ).toBe("/fakehome/.local/bin/openshell"); }); it("falls back to /usr/local/bin", () => { - expect(resolveOpenshell({ - commandVResult: null, - checkExecutable: (p) => p === "/usr/local/bin/openshell", - })).toBe("/usr/local/bin/openshell"); + expect( + resolveOpenshell({ + commandVResult: null, + checkExecutable: (p) => p === "/usr/local/bin/openshell", + }), + ).toBe("/usr/local/bin/openshell"); }); it("falls back to /usr/bin", () => { - expect(resolveOpenshell({ - commandVResult: null, - checkExecutable: (p) => p === "/usr/bin/openshell", - })).toBe("/usr/bin/openshell"); + expect( + resolveOpenshell({ + commandVResult: null, + checkExecutable: (p) => p === "/usr/bin/openshell", + }), + ).toBe("/usr/bin/openshell"); }); it("prefers ~/.local/bin over /usr/local/bin", () => { - expect(resolveOpenshell({ - commandVResult: null, - checkExecutable: (p) => p === "/fakehome/.local/bin/openshell" || p === "/usr/local/bin/openshell", - home: "/fakehome", - })).toBe("/fakehome/.local/bin/openshell"); + expect( + resolveOpenshell({ + commandVResult: null, + checkExecutable: (p) => + p === "/fakehome/.local/bin/openshell" || p === "/usr/local/bin/openshell", + home: "/fakehome", + }), + ).toBe("/fakehome/.local/bin/openshell"); }); it("returns null when openshell not found anywhere", () => { - expect(resolveOpenshell({ - commandVResult: null, - checkExecutable: () => false, - })).toBe(null); + expect( + resolveOpenshell({ + commandVResult: null, + checkExecutable: () => false, + }), + ).toBe(null); }); }); @@ -68,7 +126,7 @@ describe("service environment", () => { { encoding: "utf-8", env: { ...process.env, NEMOCLAW_SANDBOX: "", SANDBOX_NAME: "my-box" }, - } + }, ).trim(); expect(result).toBe("my-box"); }); @@ -79,7 +137,7 @@ describe("service environment", () => { { encoding: "utf-8", env: { ...process.env, NEMOCLAW_SANDBOX: "from-env", SANDBOX_NAME: "old" }, - } + }, ).trim(); expect(result).toBe("from-env"); }); @@ -90,9 +148,452 @@ describe("service environment", () => { { encoding: "utf-8", env: { ...process.env, NEMOCLAW_SANDBOX: "", SANDBOX_NAME: "" }, - } + }, ).trim(); expect(result).toBe("default"); }); }); + + describe("ALLOWED_CHAT_IDS propagation (issue #896)", () => { + const scriptPath = join(import.meta.dirname, "../scripts/start-services.sh"); + + it("start-services.sh propagates ALLOWED_CHAT_IDS to nohup child", () => { + // Patch start-services.sh to launch an env-dump script instead of the + // real telegram-bridge.js. The real bridge needs Telegram API + openshell, + // so we swap the node command with a script that writes its env to a file. + const workspace = mkdtempSync(join(tmpdir(), "nemoclaw-chatids-")); + const envDump = join(workspace, "child-env.txt"); + + // Fake node script that dumps env and exits + const fakeScript = join(workspace, "fake-bridge.js"); + writeFileSync( + fakeScript, + `require("fs").writeFileSync(${JSON.stringify(envDump)}, Object.entries(process.env).map(([k,v])=>k+"="+v).join("\\n"));`, + ); + + // Wrapper that overrides REPO_DIR so start-services.sh launches our fake + // bridge instead of the real one, and stubs out openshell + cloudflared + const wrapper = join(workspace, "run.sh"); + writeFileSync( + wrapper, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + // Create a fake repo dir with the fake bridge at the expected path + `FAKE_REPO="${workspace}/fakerepo"`, + `mkdir -p "$FAKE_REPO/scripts"`, + `cp "${fakeScript}" "$FAKE_REPO/scripts/telegram-bridge.js"`, + // Source the start function from the real script, then call it with our fake repo + `export SANDBOX_NAME="test-box"`, + `export TELEGRAM_BOT_TOKEN="test-token"`, + `export NVIDIA_API_KEY="test-key"`, + `export ALLOWED_CHAT_IDS="111,222,333"`, + // Stub openshell (prints "Ready" to pass sandbox check) and hide cloudflared + `BIN_DIR="${workspace}/bin"`, + `mkdir -p "$BIN_DIR"`, + `printf '#!/usr/bin/env bash\\necho "Ready"\\n' > "$BIN_DIR/openshell"`, + `chmod +x "$BIN_DIR/openshell"`, + `NODE_DIR="$(dirname "$(command -v node)")"`, + `export PATH="$BIN_DIR:$NODE_DIR:/usr/bin:/bin:/usr/local/bin"`, + // Run the real script but with REPO_DIR overridden via sed — also disable cloudflared + `PATCHED="${workspace}/patched-start.sh"`, + `sed -e 's|REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"|REPO_DIR="'"$FAKE_REPO"'"|' -e 's|command -v cloudflared|false|g' "${scriptPath}" > "$PATCHED"`, + `chmod +x "$PATCHED"`, + `bash "$PATCHED"`, + // Poll for the env dump file (nohup child writes it asynchronously) + `for i in $(seq 1 20); do [ -s "${envDump}" ] && break; sleep 0.1; done`, + ].join("\n"), + { mode: 0o755 }, + ); + + execFileSync("bash", [wrapper], { encoding: "utf-8", timeout: 10000 }); + + const childEnv = readFileSync(envDump, "utf-8"); + expect(childEnv).toContain("ALLOWED_CHAT_IDS=111,222,333"); + expect(childEnv).toContain("SANDBOX_NAME=test-box"); + expect(childEnv).toContain("TELEGRAM_BOT_TOKEN=test-token"); + expect(childEnv).toContain("NVIDIA_API_KEY=test-key"); + }); + + it("telegram-bridge.js imports and uses chat-filter module with correct env var", () => { + const bridgeSrc = readFileSync( + join(import.meta.dirname, "../scripts/telegram-bridge.js"), + "utf-8", + ); + // Verify it imports the module (not inline parsing) + expect(bridgeSrc).toContain('require("../bin/lib/chat-filter")'); + // Verify it parses the correct env var name (not a typo like ALLOWED_CHATS) + expect(bridgeSrc).toContain("parseAllowedChatIds(process.env.ALLOWED_CHAT_IDS)"); + // Verify it uses isChatAllowed for access control + expect(bridgeSrc).toContain("isChatAllowed(ALLOWED_CHATS, chatId)"); + // Verify the old inline pattern is gone + expect(bridgeSrc).not.toContain('.split(",").map((s) => s.trim())'); + }); + + it("nohup child can parse the propagated ALLOWED_CHAT_IDS value", () => { + // End-to-end: start-services.sh passes env to child, child parses it + // using the same chat-filter module telegram-bridge.js uses. + const workspace = mkdtempSync(join(tmpdir(), "nemoclaw-parse-e2e-")); + const resultFile = join(workspace, "parse-result.json"); + + // Fake bridge that parses ALLOWED_CHAT_IDS using chat-filter and dumps result + const chatFilterPath = join(import.meta.dirname, "../bin/lib/chat-filter.js"); + const fakeScript = join(workspace, "fake-bridge.js"); + writeFileSync( + fakeScript, + [ + `const { parseAllowedChatIds, isChatAllowed } = require(${JSON.stringify(chatFilterPath)});`, + `const parsed = parseAllowedChatIds(process.env.ALLOWED_CHAT_IDS);`, + `const result = {`, + ` raw: process.env.ALLOWED_CHAT_IDS,`, + ` parsed,`, + ` allows111: isChatAllowed(parsed, "111"),`, + ` allows999: isChatAllowed(parsed, "999"),`, + `};`, + `require("fs").writeFileSync(${JSON.stringify(resultFile)}, JSON.stringify(result));`, + ].join("\n"), + ); + + const wrapper = join(workspace, "run.sh"); + writeFileSync( + wrapper, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `FAKE_REPO="${workspace}/fakerepo"`, + `mkdir -p "$FAKE_REPO/scripts"`, + `cp "${fakeScript}" "$FAKE_REPO/scripts/telegram-bridge.js"`, + `export SANDBOX_NAME="test-box"`, + `export TELEGRAM_BOT_TOKEN="test-token"`, + `export NVIDIA_API_KEY="test-key"`, + `export ALLOWED_CHAT_IDS="111, 222 , 333"`, + // Stub openshell (prints "Ready" to pass sandbox check) and hide cloudflared + `BIN_DIR="${workspace}/bin"`, + `mkdir -p "$BIN_DIR"`, + `printf '#!/usr/bin/env bash\\necho "Ready"\\n' > "$BIN_DIR/openshell"`, + `chmod +x "$BIN_DIR/openshell"`, + `NODE_DIR="$(dirname "$(command -v node)")"`, + `export PATH="$BIN_DIR:$NODE_DIR:/usr/bin:/bin:/usr/local/bin"`, + `PATCHED="${workspace}/patched-start.sh"`, + `sed -e 's|REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"|REPO_DIR="'"$FAKE_REPO"'"|' -e 's|command -v cloudflared|false|g' "${scriptPath}" > "$PATCHED"`, + `chmod +x "$PATCHED"`, + `bash "$PATCHED"`, + `for i in $(seq 1 20); do [ -s "${resultFile}" ] && break; sleep 0.1; done`, + ].join("\n"), + { mode: 0o755 }, + ); + + execFileSync("bash", [wrapper], { encoding: "utf-8", timeout: 10000 }); + + const result = JSON.parse(readFileSync(resultFile, "utf-8")); + expect(result.raw).toBe("111, 222 , 333"); + expect(result.parsed).toEqual(["111", "222", "333"]); + expect(result.allows111).toBe(true); + expect(result.allows999).toBe(false); + }); + + it("parseAllowedChatIds parses comma-separated IDs with whitespace", () => { + expect(parseAllowedChatIds("111, 222 , 333")).toEqual(["111", "222", "333"]); + }); + + it("isChatAllowed filters blocked chat IDs", () => { + const allowed = parseAllowedChatIds("111,222"); + expect(isChatAllowed(allowed, "111")).toBe(true); + expect(isChatAllowed(allowed, "222")).toBe(true); + expect(isChatAllowed(allowed, "333")).toBe(false); + expect(isChatAllowed(allowed, "999")).toBe(false); + }); + + it("parseAllowedChatIds handles single chat ID (no commas)", () => { + expect(parseAllowedChatIds("111")).toEqual(["111"]); + }); + + it("parseAllowedChatIds filters empty entries from trailing commas", () => { + expect(parseAllowedChatIds("111,,222,")).toEqual(["111", "222"]); + }); + + it("parseAllowedChatIds returns null when unset, isChatAllowed allows all", () => { + expect(parseAllowedChatIds(undefined)).toBeNull(); + expect(parseAllowedChatIds("")).toBeNull(); + expect(isChatAllowed(null, "anyid")).toBe(true); + }); + }); + + describe("proxy environment variables (issue #626)", () => { + function extractProxyVars(env = {}) { + const scriptPath = join(import.meta.dirname, "../scripts/nemoclaw-start.sh"); + const proxyBlock = execFileSync( + "sed", + ["-n", "/^PROXY_HOST=/,/^export no_proxy=/p", scriptPath], + { encoding: "utf-8" }, + ); + if (!proxyBlock.trim()) { + throw new Error( + "Failed to extract proxy configuration from scripts/nemoclaw-start.sh — " + + "the PROXY_HOST..no_proxy block may have been moved or renamed", + ); + } + const wrapper = [ + "#!/usr/bin/env bash", + proxyBlock.trimEnd(), + 'echo "HTTP_PROXY=${HTTP_PROXY}"', + 'echo "HTTPS_PROXY=${HTTPS_PROXY}"', + 'echo "NO_PROXY=${NO_PROXY}"', + 'echo "http_proxy=${http_proxy}"', + 'echo "https_proxy=${https_proxy}"', + 'echo "no_proxy=${no_proxy}"', + ].join("\n"); + const tmpFile = join(tmpdir(), `nemoclaw-proxy-test-${process.pid}.sh`); + try { + writeFileSync(tmpFile, wrapper, { mode: 0o700 }); + const out = execFileSync("bash", [tmpFile], { + encoding: "utf-8", + env: { ...process.env, ...env }, + }).trim(); + return Object.fromEntries( + out.split("\n").map((l) => { + const idx = l.indexOf("="); + return [l.slice(0, idx), l.slice(idx + 1)]; + }), + ); + } finally { + try { + unlinkSync(tmpFile); + } catch { + /* ignore */ + } + } + } + + it("sets HTTP_PROXY to default gateway address", () => { + const vars = extractProxyVars(); + expect(vars.HTTP_PROXY).toBe("http://10.200.0.1:3128"); + }); + + it("sets HTTPS_PROXY to default gateway address", () => { + const vars = extractProxyVars(); + expect(vars.HTTPS_PROXY).toBe("http://10.200.0.1:3128"); + }); + + it("NEMOCLAW_PROXY_HOST overrides default gateway IP", () => { + const vars = extractProxyVars({ NEMOCLAW_PROXY_HOST: "192.168.64.1" }); + expect(vars.HTTP_PROXY).toBe("http://192.168.64.1:3128"); + expect(vars.HTTPS_PROXY).toBe("http://192.168.64.1:3128"); + }); + + it("NEMOCLAW_PROXY_PORT overrides default proxy port", () => { + const vars = extractProxyVars({ NEMOCLAW_PROXY_PORT: "8080" }); + expect(vars.HTTP_PROXY).toBe("http://10.200.0.1:8080"); + expect(vars.HTTPS_PROXY).toBe("http://10.200.0.1:8080"); + }); + + it("NO_PROXY includes loopback only, not inference.local", () => { + const vars = extractProxyVars(); + const noProxy = vars.NO_PROXY.split(","); + expect(noProxy).toContain("localhost"); + expect(noProxy).toContain("127.0.0.1"); + expect(noProxy).toContain("::1"); + expect(noProxy).not.toContain("inference.local"); + }); + + it("NO_PROXY includes OpenShell gateway IP", () => { + const vars = extractProxyVars(); + expect(vars.NO_PROXY).toContain("10.200.0.1"); + }); + + it("exports lowercase proxy variants for undici/gRPC compatibility", () => { + const vars = extractProxyVars(); + expect(vars.http_proxy).toBe("http://10.200.0.1:3128"); + expect(vars.https_proxy).toBe("http://10.200.0.1:3128"); + const noProxy = vars.no_proxy.split(","); + expect(noProxy).not.toContain("inference.local"); + expect(noProxy).toContain("10.200.0.1"); + }); + + it("entrypoint persistence writes proxy snippet to ~/.bashrc and ~/.profile", () => { + const fakeHome = join(tmpdir(), `nemoclaw-home-test-${process.pid}`); + execFileSync("mkdir", ["-p", fakeHome]); + const tmpFile = join(tmpdir(), `nemoclaw-bashrc-write-test-${process.pid}.sh`); + try { + const scriptPath = join(import.meta.dirname, "../scripts/nemoclaw-start.sh"); + const persistBlock = execFileSync( + "sed", + ["-n", "/^_PROXY_URL=/,/^# ── Main/{ /^# ── Main/d; p; }", scriptPath], + { encoding: "utf-8" }, + ); + const wrapper = [ + "#!/usr/bin/env bash", + 'PROXY_HOST="10.200.0.1"', + 'PROXY_PORT="3128"', + persistBlock.trimEnd(), + ].join("\n"); + writeFileSync(tmpFile, wrapper, { mode: 0o700 }); + execFileSync("bash", [tmpFile], { + encoding: "utf-8", + env: { ...process.env, HOME: fakeHome }, + }); + + const bashrc = readFileSync(join(fakeHome, ".bashrc"), "utf-8"); + expect(bashrc).toContain("export HTTP_PROXY="); + expect(bashrc).toContain("export HTTPS_PROXY="); + expect(bashrc).toContain("export NO_PROXY="); + expect(bashrc).not.toContain("inference.local"); + expect(bashrc).toContain("10.200.0.1"); + + const profile = readFileSync(join(fakeHome, ".profile"), "utf-8"); + expect(profile).not.toContain("inference.local"); + } finally { + try { + unlinkSync(tmpFile); + } catch { + /* ignore */ + } + try { + execFileSync("rm", ["-rf", fakeHome]); + } catch { + /* ignore */ + } + } + }); + + it("entrypoint persistence is idempotent across repeated invocations", () => { + const fakeHome = join(tmpdir(), `nemoclaw-idempotent-test-${process.pid}`); + execFileSync("mkdir", ["-p", fakeHome]); + const tmpFile = join(tmpdir(), `nemoclaw-idempotent-write-test-${process.pid}.sh`); + try { + const scriptPath = join(import.meta.dirname, "../scripts/nemoclaw-start.sh"); + const persistBlock = execFileSync( + "sed", + ["-n", "/^_PROXY_URL=/,/^# ── Main/{ /^# ── Main/d; p; }", scriptPath], + { encoding: "utf-8" }, + ); + const wrapper = [ + "#!/usr/bin/env bash", + 'PROXY_HOST="10.200.0.1"', + 'PROXY_PORT="3128"', + persistBlock.trimEnd(), + ].join("\n"); + writeFileSync(tmpFile, wrapper, { mode: 0o700 }); + const runOpts = { + encoding: /** @type {const} */ ("utf-8"), + env: { ...process.env, HOME: fakeHome }, + }; + execFileSync("bash", [tmpFile], runOpts); + execFileSync("bash", [tmpFile], runOpts); + execFileSync("bash", [tmpFile], runOpts); + + const bashrc = readFileSync(join(fakeHome, ".bashrc"), "utf-8"); + const beginCount = (bashrc.match(/nemoclaw-proxy-config begin/g) || []).length; + const endCount = (bashrc.match(/nemoclaw-proxy-config end/g) || []).length; + expect(beginCount).toBe(1); + expect(endCount).toBe(1); + } finally { + try { + unlinkSync(tmpFile); + } catch { + /* ignore */ + } + try { + execFileSync("rm", ["-rf", fakeHome]); + } catch { + /* ignore */ + } + } + }); + + it("entrypoint persistence replaces stale proxy values on restart", () => { + const fakeHome = join(tmpdir(), `nemoclaw-replace-test-${process.pid}`); + execFileSync("mkdir", ["-p", fakeHome]); + const tmpFile = join(tmpdir(), `nemoclaw-replace-write-test-${process.pid}.sh`); + try { + const scriptPath = join(import.meta.dirname, "../scripts/nemoclaw-start.sh"); + const persistBlock = execFileSync( + "sed", + ["-n", "/^_PROXY_URL=/,/^# ── Main/{ /^# ── Main/d; p; }", scriptPath], + { encoding: "utf-8" }, + ); + const makeWrapper = (host) => + [ + "#!/usr/bin/env bash", + `PROXY_HOST="${host}"`, + 'PROXY_PORT="3128"', + persistBlock.trimEnd(), + ].join("\n"); + + writeFileSync(tmpFile, makeWrapper("10.200.0.1"), { mode: 0o700 }); + execFileSync("bash", [tmpFile], { + encoding: "utf-8", + env: { ...process.env, HOME: fakeHome }, + }); + let bashrc = readFileSync(join(fakeHome, ".bashrc"), "utf-8"); + expect(bashrc).toContain("10.200.0.1"); + + writeFileSync(tmpFile, makeWrapper("192.168.1.99"), { mode: 0o700 }); + execFileSync("bash", [tmpFile], { + encoding: "utf-8", + env: { ...process.env, HOME: fakeHome }, + }); + bashrc = readFileSync(join(fakeHome, ".bashrc"), "utf-8"); + expect(bashrc).toContain("192.168.1.99"); + expect(bashrc).not.toContain("10.200.0.1"); + const beginCount = (bashrc.match(/nemoclaw-proxy-config begin/g) || []).length; + expect(beginCount).toBe(1); + } finally { + try { + unlinkSync(tmpFile); + } catch { + /* ignore */ + } + try { + execFileSync("rm", ["-rf", fakeHome]); + } catch { + /* ignore */ + } + } + }); + + it("[simulation] sourcing ~/.bashrc overrides narrow NO_PROXY and no_proxy", () => { + const fakeHome = join(tmpdir(), `nemoclaw-bashi-test-${process.pid}`); + execFileSync("mkdir", ["-p", fakeHome]); + try { + const bashrcContent = [ + "# nemoclaw-proxy-config begin", + 'export HTTP_PROXY="http://10.200.0.1:3128"', + 'export HTTPS_PROXY="http://10.200.0.1:3128"', + 'export NO_PROXY="localhost,127.0.0.1,::1,10.200.0.1"', + 'export http_proxy="http://10.200.0.1:3128"', + 'export https_proxy="http://10.200.0.1:3128"', + 'export no_proxy="localhost,127.0.0.1,::1,10.200.0.1"', + "# nemoclaw-proxy-config end", + ].join("\n"); + writeFileSync(join(fakeHome, ".bashrc"), bashrcContent); + + const out = execFileSync( + "bash", + [ + "--norc", + "-c", + [ + `export HOME=${JSON.stringify(fakeHome)}`, + 'export NO_PROXY="127.0.0.1,localhost,::1"', + 'export no_proxy="127.0.0.1,localhost,::1"', + `source ${JSON.stringify(join(fakeHome, ".bashrc"))}`, + 'echo "NO_PROXY=$NO_PROXY"', + 'echo "no_proxy=$no_proxy"', + ].join("; "), + ], + { encoding: "utf-8" }, + ).trim(); + + expect(out).toContain("NO_PROXY=localhost,127.0.0.1,::1,10.200.0.1"); + expect(out).toContain("no_proxy=localhost,127.0.0.1,::1,10.200.0.1"); + } finally { + try { + execFileSync("rm", ["-rf", fakeHome]); + } catch { + /* ignore */ + } + } + }); + }); }); diff --git a/test/setup-sandbox-name.test.js b/test/setup-sandbox-name.test.js deleted file mode 100644 index d9ace4ead..000000000 --- a/test/setup-sandbox-name.test.js +++ /dev/null @@ -1,67 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 -// -// Verify that setup.sh uses a parameterized sandbox name instead of -// hardcoding "nemoclaw". Gateway name must stay hardcoded. -// -// See: https://github.com/NVIDIA/NemoClaw/issues/197 - -import { describe, it, expect } from "vitest"; -import fs from "node:fs"; -import path from "node:path"; -import { execSync } from "node:child_process"; - -const ROOT = path.resolve(import.meta.dirname, ".."); - -describe("setup.sh sandbox name parameterization (#197)", () => { - const content = fs.readFileSync(path.join(ROOT, "scripts/setup.sh"), "utf-8"); - - it("accepts sandbox name as $1 with default", () => { - expect(content.includes('SANDBOX_NAME="${1:-nemoclaw}"')).toBeTruthy(); - }); - - it("sandbox create uses $SANDBOX_NAME, not hardcoded", () => { - const createLine = content.match(/openshell sandbox create.*--name\s+(\S+)/); - expect(createLine).toBeTruthy(); - expect( - createLine[1].includes("$SANDBOX_NAME") || createLine[1].includes('"$SANDBOX_NAME"') - ).toBeTruthy(); - }); - - it("sandbox delete uses $SANDBOX_NAME, not hardcoded", () => { - const deleteLine = content.match(/openshell sandbox delete\s+(\S+)/); - expect(deleteLine).toBeTruthy(); - expect( - deleteLine[1].includes("$SANDBOX_NAME") || deleteLine[1].includes('"$SANDBOX_NAME"') - ).toBeTruthy(); - }); - - it("sandbox get uses $SANDBOX_NAME, not hardcoded", () => { - const getLine = content.match(/openshell sandbox get\s+(\S+)/); - expect(getLine).toBeTruthy(); - expect( - getLine[1].includes("$SANDBOX_NAME") || getLine[1].includes('"$SANDBOX_NAME"') - ).toBeTruthy(); - }); - - it("gateway name stays hardcoded to nemoclaw", () => { - expect(content.includes("gateway destroy -g nemoclaw")).toBeTruthy(); - expect(content.includes("--name nemoclaw")).toBeTruthy(); - }); - - it("$1 arg actually sets SANDBOX_NAME in bash", () => { - const result = execSync( - 'bash -c \'SANDBOX_NAME="${1:-nemoclaw}"; echo "$SANDBOX_NAME"\' -- my-test-box', - { encoding: "utf-8" } - ).trim(); - expect(result).toBe("my-test-box"); - }); - - it("no arg defaults to nemoclaw in bash", () => { - const result = execSync( - 'bash -c \'SANDBOX_NAME="${1:-nemoclaw}"; echo "$SANDBOX_NAME"\'', - { encoding: "utf-8" } - ).trim(); - expect(result).toBe("nemoclaw"); - }); -}); diff --git a/test/uninstall.test.js b/test/uninstall.test.js index 60e7e977e..5e3709971 100644 --- a/test/uninstall.test.js +++ b/test/uninstall.test.js @@ -9,6 +9,18 @@ import { spawnSync } from "node:child_process"; const UNINSTALL_SCRIPT = path.join(import.meta.dirname, "..", "uninstall.sh"); +function createFakeNpmEnv(tmp) { + const fakeBin = path.join(tmp, "bin"); + const npmPath = path.join(fakeBin, "npm"); + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync(npmPath, "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 }); + return { + ...process.env, + HOME: tmp, + PATH: `${fakeBin}:${process.env.PATH || "/usr/bin:/bin"}`, + }; +} + describe("uninstall CLI flags", () => { it("--help exits 0 and shows usage", () => { const result = spawnSync("bash", [UNINSTALL_SCRIPT, "--help"], { @@ -30,9 +42,10 @@ describe("uninstall CLI flags", () => { fs.mkdirSync(fakeBin); try { - // Provide stub executables so the uninstaller can run its steps as no-ops for (const cmd of ["npm", "openshell", "docker", "ollama", "pgrep"]) { - fs.writeFileSync(path.join(fakeBin, cmd), "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 }); + fs.writeFileSync(path.join(fakeBin, cmd), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); } const result = spawnSync("bash", [UNINSTALL_SCRIPT, "--yes"], { @@ -47,21 +60,20 @@ describe("uninstall CLI flags", () => { }); expect(result.status).toBe(0); - // Banner and bye statement should be present const output = `${result.stdout}${result.stderr}`; expect(output).toMatch(/NemoClaw/); expect(output).toMatch(/Claws retracted/); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } - }); + }, 60_000); }); describe("uninstall helpers", () => { it("returns the expected gateway volume candidate", () => { const result = spawnSync( "bash", - ["-lc", `source "${UNINSTALL_SCRIPT}"; gateway_volume_candidates nemoclaw`], + ["-c", `source "${UNINSTALL_SCRIPT}"; gateway_volume_candidates nemoclaw`], { cwd: path.join(import.meta.dirname, ".."), encoding: "utf-8", @@ -76,19 +88,62 @@ describe("uninstall helpers", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-shim-")); const shimDir = path.join(tmp, ".local", "bin"); const shimPath = path.join(shimDir, "nemoclaw"); + const targetPath = path.join(tmp, "prefix", "bin", "nemoclaw"); + + fs.mkdirSync(shimDir, { recursive: true }); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync(targetPath, "#!/usr/bin/env bash\n", { mode: 0o755 }); + fs.symlinkSync(targetPath, shimPath); + + const result = spawnSync("bash", ["-c", `source "${UNINSTALL_SCRIPT}"; remove_nemoclaw_cli`], { + cwd: path.join(import.meta.dirname, ".."), + encoding: "utf-8", + env: createFakeNpmEnv(tmp), + }); + + expect(result.status).toBe(0); + expect(fs.existsSync(shimPath)).toBe(false); + }); + + it("preserves a user-managed nemoclaw file in the shim directory", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-preserve-")); + const shimDir = path.join(tmp, ".local", "bin"); + const shimPath = path.join(shimDir, "nemoclaw"); + fs.mkdirSync(shimDir, { recursive: true }); fs.writeFileSync(shimPath, "#!/usr/bin/env bash\n", { mode: 0o755 }); + const result = spawnSync("bash", ["-c", `source "${UNINSTALL_SCRIPT}"; remove_nemoclaw_cli`], { + cwd: path.join(import.meta.dirname, ".."), + encoding: "utf-8", + env: createFakeNpmEnv(tmp), + }); + + expect(result.status).toBe(0); + expect(fs.existsSync(shimPath)).toBe(true); + expect(`${result.stdout}${result.stderr}`).toMatch(/not an installer-managed shim/); + }); + + it("removes the onboard session file as part of NemoClaw state cleanup", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-session-")); + const stateDir = path.join(tmp, ".nemoclaw"); + const sessionPath = path.join(stateDir, "onboard-session.json"); + + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync(sessionPath, JSON.stringify({ status: "complete" })); + const result = spawnSync( "bash", - ["-lc", `HOME="${tmp}" source "${UNINSTALL_SCRIPT}"; remove_nemoclaw_cli`], + ["-c", `source "${UNINSTALL_SCRIPT}"; remove_nemoclaw_state`], { cwd: path.join(import.meta.dirname, ".."), encoding: "utf-8", + env: { ...process.env, HOME: tmp }, }, ); expect(result.status).toBe(0); - expect(fs.existsSync(shimPath)).toBe(false); + expect(fs.existsSync(sessionPath)).toBe(false); + expect(fs.existsSync(stateDir)).toBe(false); }); }); diff --git a/test/usage-notice.test.js b/test/usage-notice.test.js new file mode 100644 index 000000000..4b5659b9f --- /dev/null +++ b/test/usage-notice.test.js @@ -0,0 +1,154 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; + +const repoRoot = path.join(import.meta.dirname, ".."); +const noticePath = path.join(repoRoot, "bin", "lib", "usage-notice.js"); +const { + NOTICE_ACCEPT_FLAG, + ensureUsageNoticeConsent, + formatTerminalHyperlink, + getUsageNoticeStateFile, + hasAcceptedUsageNotice, + loadUsageNoticeConfig, + printUsageNotice, +} = require(noticePath); + +describe("usage notice", () => { + const originalIsTTY = process.stdin.isTTY; + const originalHome = process.env.HOME; + let testHome = null; + + beforeEach(() => { + testHome = fs.mkdtempSync(path.join(import.meta.dirname, "usage-notice-home-")); + process.env.HOME = testHome; + try { + fs.rmSync(getUsageNoticeStateFile(), { force: true }); + } catch { + // ignore cleanup errors + } + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: true, + }); + }); + + afterEach(() => { + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: originalIsTTY, + }); + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + if (testHome) { + fs.rmSync(testHome, { force: true, recursive: true }); + testHome = null; + } + }); + + it("requires the non-interactive acceptance flag", async () => { + const lines = []; + const ok = await ensureUsageNoticeConsent({ + nonInteractive: true, + acceptedByFlag: false, + writeLine: (line) => lines.push(line), + }); + + expect(ok).toBe(false); + expect(lines.join("\n")).toContain(NOTICE_ACCEPT_FLAG); + }); + + it("records acceptance in non-interactive mode when the flag is present", async () => { + const config = loadUsageNoticeConfig(); + const ok = await ensureUsageNoticeConsent({ + nonInteractive: true, + acceptedByFlag: true, + writeLine: () => {}, + }); + + expect(ok).toBe(true); + expect(hasAcceptedUsageNotice(config.version)).toBe(true); + }); + + it("cancels interactive onboarding unless the user types yes", async () => { + const lines = []; + const ok = await ensureUsageNoticeConsent({ + nonInteractive: false, + promptFn: async () => "no", + writeLine: (line) => lines.push(line), + }); + + expect(ok).toBe(false); + expect(lines.join("\n")).toContain("Installation cancelled"); + }); + + it("records interactive acceptance when the user types yes", async () => { + const config = loadUsageNoticeConfig(); + const ok = await ensureUsageNoticeConsent({ + nonInteractive: false, + promptFn: async () => "yes", + writeLine: () => {}, + }); + + expect(ok).toBe(true); + expect(hasAcceptedUsageNotice(config.version)).toBe(true); + }); + + it("fails interactive mode without a tty", async () => { + const lines = []; + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: false, + }); + + const ok = await ensureUsageNoticeConsent({ + nonInteractive: false, + promptFn: async () => "yes", + writeLine: (line) => lines.push(line), + }); + + expect(ok).toBe(false); + expect(lines.join("\n")).toContain("Interactive onboarding requires a TTY"); + }); + + it("renders url lines as terminal hyperlinks when tty output is available", () => { + const lines = []; + const originalStdoutIsTTY = process.stdout.isTTY; + const originalStderrIsTTY = process.stderr.isTTY; + try { + Object.defineProperty(process.stdout, "isTTY", { + configurable: true, + value: true, + }); + Object.defineProperty(process.stderr, "isTTY", { + configurable: true, + value: true, + }); + + printUsageNotice(loadUsageNoticeConfig(), (line) => lines.push(line)); + } finally { + Object.defineProperty(process.stdout, "isTTY", { + configurable: true, + value: originalStdoutIsTTY, + }); + Object.defineProperty(process.stderr, "isTTY", { + configurable: true, + value: originalStderrIsTTY, + }); + } + + expect(lines.join("\n")).toContain( + formatTerminalHyperlink( + "https://docs.openclaw.ai/gateway/security", + "https://docs.openclaw.ai/gateway/security", + ), + ); + expect(lines.join("\n")).toContain("https://docs.openclaw.ai/gateway/security"); + }); +}); diff --git a/tsconfig.cli.json b/tsconfig.cli.json new file mode 100644 index 000000000..052cf455b --- /dev/null +++ b/tsconfig.cli.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "preserve", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "strict": true, + "allowJs": true, + "checkJs": false, + "noEmit": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "moduleDetection": "force", + "types": ["node"] + }, + "include": ["bin/**/*.ts", "scripts/**/*.ts", "src/**/*.ts"], + "exclude": ["node_modules", "nemoclaw"] +} diff --git a/tsconfig.src.json b/tsconfig.src.json new file mode 100644 index 000000000..da1287ae4 --- /dev/null +++ b/tsconfig.src.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "nemoclaw", "src/**/*.test.ts"] +} diff --git a/uninstall.sh b/uninstall.sh index 4ad94911c..96468506f 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -7,7 +7,7 @@ # - NemoClaw helper services # - All OpenShell sandboxes plus the NemoClaw gateway/providers # - NemoClaw/OpenShell/OpenClaw Docker images built or pulled for the sandbox flow -# - ~/.nemoclaw plus ~/.config/{openshell,nemoclaw} state +# - ~/.nemoclaw plus ~/.config/{openshell,nemoclaw} state, including onboard-session.json # - Global nemoclaw npm install/link # - OpenShell binary if it was installed to the standard installer path # @@ -232,7 +232,7 @@ remove_file_with_optional_sudo() { return 0 fi - if [ -w "$path" ] || [ -w "$(dirname "$path")" ]; then + if [ -w "$(dirname "$path")" ]; then rm -f "$path" elif [ "${NEMOCLAW_NON_INTERACTIVE:-}" = "1" ] || [ ! -t 0 ]; then warn "Skipping privileged removal of $path in non-interactive mode." @@ -293,6 +293,39 @@ remove_openshell_resources() { run_optional "Destroyed gateway '${DEFAULT_GATEWAY}'" openshell gateway destroy -g "$DEFAULT_GATEWAY" } +# Remove NemoClaw PATH/alias entries from shell profiles. +# Handles both the current block-marker format (# NemoClaw PATH setup … +# # end NemoClaw PATH setup) and the legacy single-line alias format +# (# NemoClaw CLI alias + one alias line). +remove_nemoclaw_alias_from_profile() { + local profiles=( + "$HOME/.bashrc" + "$HOME/.zshrc" + "$HOME/.profile" + "$HOME/.config/fish/config.fish" + "$HOME/.tcshrc" + "$HOME/.cshrc" + ) + local p + for p in "${profiles[@]}"; do + [ -f "$p" ] || continue + local changed=false + # Current format: block between start/end markers. + if grep -qF '# NemoClaw PATH setup' "$p" 2>/dev/null; then + sed -i.bak '/^# NemoClaw PATH setup$/,/^# end NemoClaw PATH setup$/d' "$p" && rm -f "${p}.bak" + changed=true + fi + # Legacy format: marker + one alias line. + if grep -qF '# NemoClaw CLI alias' "$p" 2>/dev/null; then + sed -i.bak '/^# NemoClaw CLI alias$/{N;d;}' "$p" && rm -f "${p}.bak" + changed=true + fi + if [ "$changed" = true ]; then + info "Removed NemoClaw PATH entries from $p" + fi + done +} + remove_nemoclaw_cli() { if command -v npm >/dev/null 2>&1; then npm unlink -g nemoclaw >/dev/null 2>&1 || true @@ -305,9 +338,13 @@ remove_nemoclaw_cli() { warn "npm not found; skipping nemoclaw npm uninstall." fi - if [ -L "${NEMOCLAW_SHIM_DIR}/nemoclaw" ] || [ -f "${NEMOCLAW_SHIM_DIR}/nemoclaw" ]; then + if [ -L "${NEMOCLAW_SHIM_DIR}/nemoclaw" ]; then remove_path "${NEMOCLAW_SHIM_DIR}/nemoclaw" + elif [ -f "${NEMOCLAW_SHIM_DIR}/nemoclaw" ]; then + warn "Leaving ${NEMOCLAW_SHIM_DIR}/nemoclaw in place because it is not an installer-managed shim." fi + + remove_nemoclaw_alias_from_profile } remove_docker_resources() { @@ -491,6 +528,46 @@ remove_optional_ollama_models() { done } +remove_nemoclaw_swap() { + if [ ! -f /swapfile ]; then + info "No /swapfile found; skipping swap cleanup." + return 0 + fi + + if [ ! -f "$NEMOCLAW_STATE_DIR/managed_swap" ]; then + warn "No NemoClaw-managed swap marker found, skipping swap cleanup." + return 0 + fi + + local swap_file + swap_file=$(cat "$NEMOCLAW_STATE_DIR/managed_swap" 2>/dev/null || echo "") + if [ "$swap_file" != "/swapfile" ]; then + warn "Marker file does not point to /swapfile, skipping swap cleanup." + return 0 + fi + + if [ "${NEMOCLAW_NON_INTERACTIVE:-}" = "1" ] || [ ! -t 0 ]; then + warn "Skipping swap cleanup in non-interactive mode (requires sudo)." + return 0 + fi + + info "Deactivating and removing /swapfile..." + sudo swapoff /swapfile 2>/dev/null || true + sudo rm -f /swapfile + + if [ -f /swapfile ]; then + warn "Failed to remove /swapfile. Please remove it manually." + return 1 + fi + + # Clean fstab entry + if grep -q '/swapfile' /etc/fstab 2>/dev/null; then + sudo sed -i '\|^/swapfile[[:space:]]|d' /etc/fstab + info "Removed /swapfile entry from /etc/fstab" + fi + info "Swap file removed" +} + remove_runtime_temp_artifacts() { remove_glob_paths "${TMP_ROOT}/nemoclaw-create-*.log" remove_glob_paths "${TMP_ROOT}/nemoclaw-tg-ssh-*.conf" @@ -543,6 +620,10 @@ main() { remove_optional_ollama_models step 6 "State and binaries" + info "Removing NemoClaw-managed swap file..." + remove_nemoclaw_swap + + info "Removing runtime temp artifacts..." remove_runtime_temp_artifacts remove_openshell_binary remove_nemoclaw_state diff --git a/uv.lock b/uv.lock index 4b2162115..75ea8e513 100644 --- a/uv.lock +++ b/uv.lock @@ -347,6 +347,7 @@ docs = [ { name = "sphinx-copybutton" }, { name = "sphinx-design" }, { name = "sphinx-llm" }, + { name = "sphinx-reredirects" }, { name = "sphinxcontrib-mermaid" }, ] @@ -361,6 +362,7 @@ docs = [ { name = "sphinx-copybutton", specifier = "<=0.6" }, { name = "sphinx-design" }, { name = "sphinx-llm", specifier = ">=0.3.0" }, + { name = "sphinx-reredirects" }, { name = "sphinxcontrib-mermaid" }, ] @@ -595,6 +597,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c2/8f/9fecf3d081d5cd49eff83a17b9fef50ed741e6223ab3bb906de4ab0068f9/sphinx_markdown_builder-0.6.10-py3-none-any.whl", hash = "sha256:16d86738b9ac69fcbc86e373c31c6402c30af1fa8d98d0f62cc5f38bfe5fc26e", size = 16700, upload-time = "2026-03-11T10:56:56.135Z" }, ] +[[package]] +name = "sphinx-reredirects" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1b/8d/0e39fe2740d7d71417edf9a6424aa80ca2c27c17fc21282cdc39f90d5a40/sphinx_reredirects-1.1.0.tar.gz", hash = "sha256:fb9b195335ab14b43f8273287d0c7eeb637ba6c56c66581c11b47202f6718b29", size = 614624, upload-time = "2025-12-22T08:28:02.792Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/81/b5dd07067f3daac6d23687ec737b2d593740671ebcd145830c8f92d381c5/sphinx_reredirects-1.1.0-py3-none-any.whl", hash = "sha256:4b5692273c72cd2d4d917f4c6f87d5919e4d6114a752d4be033f7f5f6310efd9", size = 6351, upload-time = "2025-12-22T08:27:59.724Z" }, +] + [[package]] name = "sphinxcontrib-applehelp" version = "2.0.0" diff --git a/vitest.config.ts b/vitest.config.ts index 5b7727919..5296f9efb 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ { test: { name: "cli", - include: ["test/**/*.test.{js,ts}"], + include: ["test/**/*.test.{js,ts}", "src/**/*.test.ts"], exclude: ["**/node_modules/**", "**/.claude/**", "test/e2e/**"], }, },