diff --git a/.coderabbit.yaml b/.coderabbit.yaml index b3f12be7541..62ae3ce0f19 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -341,6 +341,31 @@ reviews: gh workflow run nightly-e2e.yaml --ref -f jobs=shields-config-e2e ``` + - path: "src/lib/actions/sandbox/brew.ts" + instructions: &e2e-brew-command | + This file owns the `nemoclaw brew {init,install,uninstall,deinit}` + lifecycle: Linuxbrew bootstrap, formula install/uninstall via the + linuxbrew user, profile.d PATH write, and prefix teardown. Changes + here affect every brew subcommand. + + **E2E test recommendation:** + - `brew-command-e2e` — TC-BREW-01..07: init/install/uninstall/deinit + + idempotent init + shields-up refusal + install-without-init refusal + + To run selectively: + ``` + gh workflow run nightly-e2e.yaml --ref -f jobs=brew-command-e2e + ``` + + - path: "src/commands/sandbox/brew/**" + instructions: *e2e-brew-command + + - path: "src/commands/sandbox/brew.ts" + instructions: *e2e-brew-command + + - path: "test/e2e/test-brew-command.sh" + instructions: *e2e-brew-command + - path: "agents/hermes/**" instructions: | This directory contains the Hermes agent. Changes affect diff --git a/.github/workflows/nightly-e2e.yaml b/.github/workflows/nightly-e2e.yaml index 4a3ef528269..4ea1a0d18f2 100644 --- a/.github/workflows/nightly-e2e.yaml +++ b/.github/workflows/nightly-e2e.yaml @@ -84,7 +84,7 @@ on: openclaw-inference-switch-e2e, network-policy-e2e, state-backup-restore-e2e, tunnel-lifecycle-e2e, diagnostics-e2e, credential-migration-e2e, - snapshot-commands-e2e, shields-config-e2e, rebuild-openclaw-e2e, + snapshot-commands-e2e, brew-command-e2e, shields-config-e2e, rebuild-openclaw-e2e, upgrade-stale-sandbox-e2e, rebuild-hermes-e2e, rebuild-hermes-stale-base-e2e, double-onboard-e2e, onboard-repair-e2e, onboard-resume-e2e, onboard-negative-paths-e2e, @@ -1420,6 +1420,41 @@ jobs: path: /tmp/nemoclaw-e2e-install.log if-no-files-found: ignore + # ── Brew subcommand lifecycle E2E (#3757) ──────────────────── + # TC-BREW-01..07: brew init/install/uninstall/deinit, idempotent init, + # shields-up refusal, install-without-init refusal. + brew-command-e2e: + if: >- + github.repository == 'NVIDIA/NemoClaw' && + (github.event_name != 'workflow_dispatch' || + inputs.jobs == '' || + contains(format(',{0},', inputs.jobs), ',brew-command-e2e,')) + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.target_ref || github.ref }} + persist-credentials: false + + - name: Run brew command E2E test + env: + NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }} + NEMOCLAW_NON_INTERACTIVE: "1" + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1" + NEMOCLAW_SANDBOX_NAME: "e2e-brew-cmd" + GITHUB_TOKEN: ${{ github.token }} + run: bash test/e2e/test-brew-command.sh + + - name: Upload test log on failure + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: brew-command-test-log + path: test-brew-command-*.log + if-no-files-found: ignore + # ── Shields & config lifecycle E2E ─────────────────────────── # Validates shields down/up controls config mutability, config get/set/ # rotate-token, audit trail, and auto-restore timer. @@ -2216,6 +2251,7 @@ jobs: diagnostics-e2e, credential-migration-e2e, snapshot-commands-e2e, + brew-command-e2e, shields-config-e2e, rebuild-openclaw-e2e, upgrade-stale-sandbox-e2e, @@ -2312,6 +2348,7 @@ jobs: diagnostics-e2e, credential-migration-e2e, snapshot-commands-e2e, + brew-command-e2e, shields-config-e2e, rebuild-openclaw-e2e, upgrade-stale-sandbox-e2e, @@ -2465,6 +2502,7 @@ jobs: diagnostics-e2e, credential-migration-e2e, snapshot-commands-e2e, + brew-command-e2e, shields-config-e2e, rebuild-openclaw-e2e, upgrade-stale-sandbox-e2e, diff --git a/docs/get-started/quickstart.mdx b/docs/get-started/quickstart.mdx index 3d7171ff033..eea8e42c123 100644 --- a/docs/get-started/quickstart.mdx +++ b/docs/get-started/quickstart.mdx @@ -280,7 +280,7 @@ For example, Slack bot tokens must start with `xoxb-`. ### Choose Network Policy Presets After the sandbox image builds and OpenClaw starts inside the sandbox, NemoClaw asks which network policy tier to apply. -The default **Balanced** tier includes common development presets such as npm, PyPI, Hugging Face, Homebrew, and Brave Search when the selected agent supports web search. +The default **Balanced** tier includes common development presets such as npm, PyPI, Hugging Face, and Brave Search when the selected agent supports web search. Use the arrow keys or `j` and `k` to move, Space to select, and Enter to confirm. The preset selector lets you include more destinations, such as GitHub, Jira, Slack, Telegram, or local inference. diff --git a/docs/network-policy/customize-network-policy.mdx b/docs/network-policy/customize-network-policy.mdx index bb8d071a6b3..9f7c6a71f9d 100644 --- a/docs/network-policy/customize-network-policy.mdx +++ b/docs/network-policy/customize-network-policy.mdx @@ -172,7 +172,6 @@ Available presets: | Preset | Endpoints | |--------|-----------| | `brave` | Brave Search API | -| `brew` | Homebrew (Linuxbrew) package manager | | `discord` | Discord API, gateway, and CDN access | | `github` | GitHub and GitHub REST API | | `huggingface` | Hugging Face Hub (download-only) and inference router | diff --git a/docs/network-policy/integration-policy-examples.mdx b/docs/network-policy/integration-policy-examples.mdx index debd07fb580..94fe9cfcb3c 100644 --- a/docs/network-policy/integration-policy-examples.mdx +++ b/docs/network-policy/integration-policy-examples.mdx @@ -54,7 +54,6 @@ NemoClaw ships maintained policy presets for common services in `nemoclaw-bluepr | Workflow | Preset | |----------|--------| | Brave Search | `brave` | -| Homebrew packages | `brew` | | Discord messaging | `discord` | | GitHub and GitHub API | `github` | | Hugging Face Hub and Inference API | `huggingface` | @@ -205,7 +204,6 @@ Use these presets when an agent workflow installs packages or downloads model as |----------|--------| | npm or Yarn packages | `npm` | | Python packages from PyPI | `pypi` | -| Homebrew packages | `brew` | | Hugging Face model or dataset access | `huggingface` | Add only the preset required for the task: @@ -213,7 +211,6 @@ Add only the preset required for the task: ```console $ nemoclaw my-assistant policy-add npm --yes $ nemoclaw my-assistant policy-add pypi --yes -$ nemoclaw my-assistant policy-add brew --yes $ nemoclaw my-assistant policy-add huggingface --yes ``` @@ -222,10 +219,18 @@ Remove package access after a one-time setup task if the sandbox no longer needs ```console $ nemoclaw my-assistant policy-remove npm --yes $ nemoclaw my-assistant policy-remove pypi --yes -$ nemoclaw my-assistant policy-remove brew --yes $ nemoclaw my-assistant policy-remove huggingface --yes ``` +Homebrew is installed via a first-class subcommand rather than a network preset: + +```console +$ nemoclaw my-assistant brew init +$ nemoclaw my-assistant brew install hello +``` + +See [`nemoclaw brew init`](../reference/commands.mdx#nemoclaw-name-brew-init) for the full lifecycle (init, install, uninstall, deinit). + ## Local Inference Use `local-inference` when the sandbox needs access to host-side local inference services such as Ollama or vLLM through the OpenShell host gateway. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 8c1028330c4..c237b1fe15c 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -695,6 +695,59 @@ Files with unsafe path characters are rejected to prevent shell injection. If the skill already exists on the sandbox, the command updates it in place and preserves chat history. For new installs, the agent session index is refreshed so the agent discovers the skill on the next session. +### `nemoclaw brew init` + +Bootstrap Homebrew (Linuxbrew) inside the sandbox. +Creates the `linuxbrew` user inside the sandbox and runs the canonical Linuxbrew installer. +The bottled `brew` binary lands at `/home/linuxbrew/.linuxbrew/bin/brew`. +Refuses when shields are up — run `nemoclaw shields down` first. + +```console +$ nemoclaw my-assistant brew init +``` + +Homebrew refuses to run as anyone other than the prefix owner, so the agent inside the sandbox cannot invoke `brew install` directly. Bottled binaries dropped by `brew install` (for example `/home/linuxbrew/.linuxbrew/bin/jq`) survive `shields up` and remain runnable by their absolute path. `brew init` also writes `/etc/profile.d/nemoclaw-linuxbrew.sh`, so new login shells inside the sandbox (e.g. `nemoclaw connect` and `bash -lc ...`) pick up the linuxbrew prefix on `PATH`. Long-running processes started before `brew init` (including the OpenClaw agent) keep their original PATH; restart them or invoke the binaries by absolute path if they need access. + +### `nemoclaw brew install` + +Install one or more Homebrew formulae via the `linuxbrew` user. +Refuses when shields are up or when `brew init` has not been run. + +```console +$ nemoclaw my-assistant brew install hello +$ nemoclaw my-assistant brew install jq curl +``` + +| Flag | Description | +|------|-------------| +| `--yes`, `-y` | When set together with `NEMOCLAW_NON_INTERACTIVE=1`, auto-run `brew init` first if Homebrew is not yet installed in the sandbox. | + +For unattended automation: + +```console +$ NEMOCLAW_NON_INTERACTIVE=1 nemoclaw my-assistant brew install hello --yes +``` + +`--yes` alone (without `NEMOCLAW_NON_INTERACTIVE=1`) keeps the explicit-init requirement, so the auto-init only triggers in scripted contexts that already opt into non-interactive behaviour repo-wide. + +### `nemoclaw brew uninstall` + +Uninstall one or more Homebrew formulae via the `linuxbrew` user. +Refuses when shields are up or when `brew init` has not been run. + +```console +$ nemoclaw my-assistant brew uninstall hello +``` + +### `nemoclaw brew deinit` + +Remove the `linuxbrew` user and the entire Homebrew prefix (`/home/linuxbrew`). +Refuses when shields are up. + +```console +$ nemoclaw my-assistant brew deinit +``` + ### `nemoclaw rebuild` Upgrade a sandbox to the current agent version while preserving workspace state. diff --git a/docs/reference/network-policies.mdx b/docs/reference/network-policies.mdx index 19dc4cc295c..3a0aa605472 100644 --- a/docs/reference/network-policies.mdx +++ b/docs/reference/network-policies.mdx @@ -63,8 +63,8 @@ The baseline policy is always applied regardless of the selected tier. | Tier | Presets included | Description | |------|------------------|-------------| | Restricted | None | Base sandbox only. No third-party network access beyond inference and core agent tooling. | -| Balanced (default) | `npm`, `pypi`, `huggingface`, `brew`, `brave when supported` | Full dev tooling and web search for agents that support web search. No messaging platform access. | -| Open | `npm`, `pypi`, `huggingface`, `brew`, `brave when supported`, `slack`, `discord`, `telegram`, `whatsapp`, `jira`, `outlook` | Broad access across third-party services including messaging and productivity. | +| Balanced (default) | `npm`, `pypi`, `huggingface`, `brave when supported` | Full dev tooling and web search for agents that support web search. No messaging platform access. | +| Open | `npm`, `pypi`, `huggingface`, `brave when supported`, `slack`, `discord`, `telegram`, `wechat`, `whatsapp`, `jira`, `outlook` | Broad access across third-party services including messaging and productivity. | After selecting a tier, a combined preset and access-mode screen lets you include or exclude individual presets and toggle each between read (GET only) and read-write (GET + POST/PUT/PATCH) access. Tier-default presets are pre-selected; additional presets can be added from the full list. diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index 79334d2c61d..9e1a09b3138 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -159,7 +159,6 @@ NemoClaw ships preset policy files in `nemoclaw-blueprint/policies/presets/` for | Preset | What it enables | Key risk | |---|---|---| | `brave` | Brave Search API. | Agent can issue search queries. | -| `brew` | Homebrew (Linuxbrew) package manager. | Allows installing arbitrary Homebrew packages, which may contain malicious code. | | `discord` | Discord REST API, WebSocket gateway, CDN. | CDN endpoint (`cdn.discordapp.com`) allows GET to any path. WebSocket uses `access: full` (no inspection). | | `github` | GitHub and GitHub REST API. | Gives agent read/write access to repositories and issues via `git`. | | `huggingface` | Hugging Face Hub (download-only) and inference router. | Allows downloading arbitrary models and datasets. POST is restricted to the inference router only. | diff --git a/nemoclaw-blueprint/policies/integrations/brew.yaml b/nemoclaw-blueprint/policies/integrations/brew.yaml new file mode 100644 index 00000000000..8a5d771dbe4 --- /dev/null +++ b/nemoclaw-blueprint/policies/integrations/brew.yaml @@ -0,0 +1,61 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Internal policy applied by `nemoclaw brew init` and removed by +# `nemoclaw brew deinit`. Not listed as a user-facing preset +# (this file lives outside `policies/presets/`, so `listPresets()` and +# `policy-add` cannot see it). The binaries section is the load-bearing +# half — without it, Landlock denies execve on /home/linuxbrew/.linuxbrew/* +# from inside the sandbox. + +preset: + name: __brew_runtime__ + description: "Homebrew (Linuxbrew) runtime — applied by `brew init`." + +network_policies: + brew: + name: brew + endpoints: + - host: formulae.brew.sh + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - host: github.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - host: ghcr.io + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/token" } + - host: pkg-containers.githubusercontent.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - host: objects.githubusercontent.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - host: raw.githubusercontent.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + binaries: + - { path: /usr/bin/curl } + - { path: /usr/bin/git } + - { path: /home/linuxbrew/.linuxbrew/bin/brew } + - { path: /home/linuxbrew/.linuxbrew/bin/* } + - { path: /home/linuxbrew/.linuxbrew/Homebrew/bin/* } diff --git a/nemoclaw-blueprint/policies/presets/brew.yaml b/nemoclaw-blueprint/policies/presets/brew.yaml deleted file mode 100644 index 55b6ab43f13..00000000000 --- a/nemoclaw-blueprint/policies/presets/brew.yaml +++ /dev/null @@ -1,41 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -preset: - name: brew - description: "Homebrew (Linuxbrew) package manager access" - -network_policies: - brew: - name: brew - endpoints: - - host: formulae.brew.sh - port: 443 - access: full - tls: skip - - host: github.com - port: 443 - access: full - tls: skip - - host: ghcr.io - port: 443 - access: full - tls: skip - - host: pkg-containers.githubusercontent.com - port: 443 - access: full - tls: skip - - host: objects.githubusercontent.com - port: 443 - access: full - tls: skip - - host: raw.githubusercontent.com - port: 443 - access: full - tls: skip - binaries: - - { path: /usr/bin/curl } - - { path: /usr/bin/git } - - { path: /home/linuxbrew/.linuxbrew/bin/brew } - - { path: /home/linuxbrew/.linuxbrew/bin/* } - - { path: /home/linuxbrew/.linuxbrew/Homebrew/bin/* } diff --git a/nemoclaw-blueprint/policies/tiers.yaml b/nemoclaw-blueprint/policies/tiers.yaml index 8a090549712..f43aff63376 100644 --- a/nemoclaw-blueprint/policies/tiers.yaml +++ b/nemoclaw-blueprint/policies/tiers.yaml @@ -24,7 +24,6 @@ tiers: - { name: npm, access: read-write } - { name: pypi, access: read-write } - { name: huggingface, access: read-write } - - { name: brew, access: read-write } - { name: brave, access: read-write } - name: open @@ -34,7 +33,6 @@ tiers: - { name: npm, access: read-write } - { name: pypi, access: read-write } - { name: huggingface, access: read-write } - - { name: brew, access: read-write } - { name: brave, access: read-write } - { name: slack, access: read-write } - { name: discord, access: read-write } diff --git a/src/commands/sandbox/brew.test.ts b/src/commands/sandbox/brew.test.ts new file mode 100644 index 00000000000..0ebfa46743f --- /dev/null +++ b/src/commands/sandbox/brew.test.ts @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const runSandboxBrew = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); + +vi.mock("../../lib/actions/sandbox/brew", () => ({ + runSandboxBrew, +})); + +import BrewCommand from "./brew"; +import BrewInitCommand from "./brew/init"; +import BrewDeinitCommand from "./brew/deinit"; +import BrewInstallCommand from "./brew/install"; +import BrewUninstallCommand from "./brew/uninstall"; + +const rootDir = process.cwd(); + +describe("brew oclif commands", () => { + beforeEach(() => { + runSandboxBrew.mockClear(); + }); + + it("parent surfaces help through the action", async () => { + await BrewCommand.run(["alpha"], rootDir); + expect(runSandboxBrew).toHaveBeenCalledWith("alpha", { kind: "help" }); + }); + + it("rejects unknown parent args before dispatch", async () => { + await expect(BrewCommand.run(["alpha", "bogus"], rootDir)).rejects.toThrow(/bogus/); + expect(runSandboxBrew).not.toHaveBeenCalled(); + }); + + it("init dispatches with kind=init", async () => { + await BrewInitCommand.run(["alpha"], rootDir); + expect(runSandboxBrew).toHaveBeenCalledWith("alpha", { kind: "init" }); + }); + + it("deinit dispatches with kind=deinit", async () => { + await BrewDeinitCommand.run(["alpha"], rootDir); + expect(runSandboxBrew).toHaveBeenCalledWith("alpha", { kind: "deinit" }); + }); + + it("install threads variadic packages into the action", async () => { + await BrewInstallCommand.run(["alpha", "hello", "jq"], rootDir); + expect(runSandboxBrew).toHaveBeenCalledWith("alpha", { + kind: "install", + packages: ["hello", "jq"], + yes: undefined, + }); + }); + + it("install threads --yes through to the action", async () => { + await BrewInstallCommand.run(["alpha", "hello", "--yes"], rootDir); + expect(runSandboxBrew).toHaveBeenCalledWith("alpha", { + kind: "install", + packages: ["hello"], + yes: true, + }); + }); + + it("uninstall threads variadic packages into the action", async () => { + await BrewUninstallCommand.run(["alpha", "hello"], rootDir); + expect(runSandboxBrew).toHaveBeenCalledWith("alpha", { + kind: "uninstall", + packages: ["hello"], + }); + }); + + it("install refuses when no packages supplied", async () => { + await BrewInstallCommand.run(["alpha"], rootDir); + expect(runSandboxBrew).not.toHaveBeenCalled(); + }); + + it("uninstall refuses when no packages supplied", async () => { + await BrewUninstallCommand.run(["alpha"], rootDir); + expect(runSandboxBrew).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/sandbox/brew.ts b/src/commands/sandbox/brew.ts new file mode 100644 index 00000000000..8fb49b85d94 --- /dev/null +++ b/src/commands/sandbox/brew.ts @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { runSandboxBrew } from "../../lib/actions/sandbox/brew"; +import { NemoClawCommand } from "../../lib/cli/nemoclaw-oclif-command"; +import { brewCommandError, sandboxNameArg } from "../../lib/sandbox/brew-command-support"; + +export default class BrewCommand extends NemoClawCommand { + static id = "sandbox:brew"; + static strict = true; + static summary = "Show brew usage"; + static description = "Show brew usage for init, deinit, install, and uninstall subcommands."; + static usage = [""]; + static examples = [ + "<%= config.bin %> sandbox brew init alpha", + "<%= config.bin %> sandbox brew install alpha hello jq", + "<%= config.bin %> sandbox brew uninstall alpha hello", + "<%= config.bin %> sandbox brew deinit alpha", + ]; + static args = { + sandboxName: sandboxNameArg, + }; + + public async run(): Promise { + const { args } = await this.parse(BrewCommand); + try { + await runSandboxBrew(args.sandboxName, { kind: "help" }); + } catch (error) { + const brewError = brewCommandError(error); + if (brewError) { + this.failWithLines(brewError.lines, brewError.exitCode); + return; + } + throw error; + } + } +} diff --git a/src/commands/sandbox/brew/deinit.ts b/src/commands/sandbox/brew/deinit.ts new file mode 100644 index 00000000000..bbf99593e2a --- /dev/null +++ b/src/commands/sandbox/brew/deinit.ts @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { runSandboxBrew } from "../../../lib/actions/sandbox/brew"; +import type { PublicCommandDisplayEntry } from "../../../lib/cli/command-display"; +import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; +import { brewCommandError, sandboxNameArg } from "../../../lib/sandbox/brew-command-support"; + +export default class BrewDeinitCommand extends NemoClawCommand { + static id = "sandbox:brew:deinit"; + static strict = true; + static summary = "Remove Homebrew from the sandbox"; + static description = + "Remove the linuxbrew user and the Homebrew prefix from the sandbox."; + static usage = [""]; + static examples = ["<%= config.bin %> sandbox brew deinit alpha"]; + static publicDisplay = [ + { + usage: "nemoclaw brew deinit", + description: "Remove Homebrew from the sandbox", + group: "Sandbox Management", + scope: "sandbox", + order: 28, + }, + ] satisfies readonly PublicCommandDisplayEntry[]; + static args = { + sandboxName: sandboxNameArg, + }; + + public async run(): Promise { + const { args } = await this.parse(BrewDeinitCommand); + try { + await runSandboxBrew(args.sandboxName, { kind: "deinit" }); + } catch (error) { + const brewError = brewCommandError(error); + if (brewError) { + this.failWithLines(brewError.lines, brewError.exitCode); + return; + } + throw error; + } + } +} diff --git a/src/commands/sandbox/brew/init.ts b/src/commands/sandbox/brew/init.ts new file mode 100644 index 00000000000..cb03c572e30 --- /dev/null +++ b/src/commands/sandbox/brew/init.ts @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { runSandboxBrew } from "../../../lib/actions/sandbox/brew"; +import type { PublicCommandDisplayEntry } from "../../../lib/cli/command-display"; +import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; +import { brewCommandError, sandboxNameArg } from "../../../lib/sandbox/brew-command-support"; + +export default class BrewInitCommand extends NemoClawCommand { + static id = "sandbox:brew:init"; + static strict = true; + static summary = "Bootstrap Homebrew (Linuxbrew) inside the sandbox"; + static description = + "Create the linuxbrew user inside the sandbox and run the canonical Linuxbrew installer."; + static usage = [""]; + static examples = ["<%= config.bin %> sandbox brew init alpha"]; + static publicDisplay = [ + { + usage: "nemoclaw brew init", + description: "Bootstrap Homebrew inside the sandbox", + group: "Sandbox Management", + scope: "sandbox", + order: 25, + }, + ] satisfies readonly PublicCommandDisplayEntry[]; + static args = { + sandboxName: sandboxNameArg, + }; + + public async run(): Promise { + const { args } = await this.parse(BrewInitCommand); + try { + await runSandboxBrew(args.sandboxName, { kind: "init" }); + } catch (error) { + const brewError = brewCommandError(error); + if (brewError) { + this.failWithLines(brewError.lines, brewError.exitCode); + return; + } + throw error; + } + } +} diff --git a/src/commands/sandbox/brew/install.ts b/src/commands/sandbox/brew/install.ts new file mode 100644 index 00000000000..7d4414db274 --- /dev/null +++ b/src/commands/sandbox/brew/install.ts @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Flags } from "@oclif/core"; +import { runSandboxBrew } from "../../../lib/actions/sandbox/brew"; +import type { PublicCommandDisplayEntry } from "../../../lib/cli/command-display"; +import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; +import { brewCommandError } from "../../../lib/sandbox/brew-command-support"; + +export default class BrewInstallCommand extends NemoClawCommand { + static id = "sandbox:brew:install"; + static strict = false; + static summary = "Install one or more Homebrew formulae"; + static description = + "Install one or more Homebrew formulae into the sandbox via the linuxbrew user. Pair --yes with NEMOCLAW_NON_INTERACTIVE=1 to auto-run `brew init` when Homebrew is not yet bootstrapped."; + static usage = [" ... [--yes|-y]"]; + static examples = [ + "<%= config.bin %> sandbox brew install alpha hello", + "<%= config.bin %> sandbox brew install alpha jq curl", + "NEMOCLAW_NON_INTERACTIVE=1 <%= config.bin %> sandbox brew install alpha hello --yes", + ]; + static publicDisplay = [ + { + usage: "nemoclaw brew install", + description: "Install one or more Homebrew formulae", + flags: "... [--yes|-y]", + group: "Sandbox Management", + scope: "sandbox", + order: 26, + }, + ] satisfies readonly PublicCommandDisplayEntry[]; + static flags = { + yes: Flags.boolean({ + char: "y", + description: + "When set together with NEMOCLAW_NON_INTERACTIVE=1, auto-run `brew init` first if Homebrew is not yet installed in the sandbox.", + }), + }; + + public async run(): Promise { + const { argv, flags } = await this.parse(BrewInstallCommand); + const [sandboxName, ...packages] = argv as string[]; + if (!sandboxName || sandboxName.trim() === "") { + this.failWithLines(["Missing required sandboxName for brew install."], 2); + return; + } + if (packages.length === 0) { + this.failWithLines(["Specify at least one formula to install."], 2); + return; + } + try { + await runSandboxBrew(sandboxName, { kind: "install", packages, yes: flags.yes }); + } catch (error) { + const brewError = brewCommandError(error); + if (brewError) { + this.failWithLines(brewError.lines, brewError.exitCode); + return; + } + throw error; + } + } +} diff --git a/src/commands/sandbox/brew/uninstall.ts b/src/commands/sandbox/brew/uninstall.ts new file mode 100644 index 00000000000..498c8e904e5 --- /dev/null +++ b/src/commands/sandbox/brew/uninstall.ts @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { runSandboxBrew } from "../../../lib/actions/sandbox/brew"; +import type { PublicCommandDisplayEntry } from "../../../lib/cli/command-display"; +import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; +import { brewCommandError } from "../../../lib/sandbox/brew-command-support"; + +export default class BrewUninstallCommand extends NemoClawCommand { + static id = "sandbox:brew:uninstall"; + static strict = false; + static summary = "Uninstall one or more Homebrew formulae"; + static description = + "Uninstall one or more Homebrew formulae from the sandbox via the linuxbrew user."; + static usage = [" ..."]; + static examples = [ + "<%= config.bin %> sandbox brew uninstall alpha hello", + "<%= config.bin %> sandbox brew uninstall alpha jq curl", + ]; + static publicDisplay = [ + { + usage: "nemoclaw brew uninstall", + description: "Uninstall one or more Homebrew formulae", + flags: "...", + group: "Sandbox Management", + scope: "sandbox", + order: 27, + }, + ] satisfies readonly PublicCommandDisplayEntry[]; + + public async run(): Promise { + const { argv } = await this.parse(BrewUninstallCommand); + const [sandboxName, ...packages] = argv as string[]; + if (!sandboxName || sandboxName.trim() === "") { + this.failWithLines(["Missing required sandboxName for brew uninstall."], 2); + return; + } + if (packages.length === 0) { + this.failWithLines(["Specify at least one formula to uninstall."], 2); + return; + } + try { + await runSandboxBrew(sandboxName, { kind: "uninstall", packages }); + } catch (error) { + const brewError = brewCommandError(error); + if (brewError) { + this.failWithLines(brewError.lines, brewError.exitCode); + return; + } + throw error; + } + } +} diff --git a/src/lib/actions/sandbox/brew.test.ts b/src/lib/actions/sandbox/brew.test.ts new file mode 100644 index 00000000000..1964f56ceb4 --- /dev/null +++ b/src/lib/actions/sandbox/brew.test.ts @@ -0,0 +1,270 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const isShieldsDown = vi.hoisted(() => vi.fn()); +const privilegedSandboxExec = vi.hoisted(() => vi.fn()); +const getSandbox = vi.hoisted(() => vi.fn()); +const updateSandbox = vi.hoisted(() => vi.fn()); + +vi.mock("../../shields", () => ({ + isShieldsDown, +})); + +vi.mock("../../adapters/sandbox/privileged-exec", () => ({ + privilegedSandboxExec, +})); + +vi.mock("../../state/registry", () => ({ + getSandbox, + updateSandbox, +})); + +vi.mock("../../policy", () => ({ + extractPresetEntries: vi.fn(() => null), + parseCurrentPolicy: vi.fn(() => ""), + mergePresetIntoPolicy: vi.fn((_a: string, _b: string) => ""), + removePresetFromPolicy: vi.fn((current: string) => current), + buildPolicyGetCommand: vi.fn(() => []), + buildPolicySetCommand: vi.fn(() => []), +})); + +vi.mock("../../runner", () => ({ + ROOT: "/tmp", + run: vi.fn(), + runCapture: vi.fn(() => ""), +})); + +import { BrewCommandError, runSandboxBrew } from "./brew"; + +function fail(message: string): never { + throw new Error(message); +} + +describe("runSandboxBrew", () => { + beforeEach(() => { + isShieldsDown.mockReset(); + privilegedSandboxExec.mockReset(); + getSandbox.mockReset(); + updateSandbox.mockReset(); + updateSandbox.mockReturnValue(true); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + describe("init", () => { + it("refuses when sandbox is not registered", async () => { + getSandbox.mockReturnValue(null); + await expect(runSandboxBrew("nonexistent", { kind: "init" })).rejects.toBeInstanceOf( + BrewCommandError, + ); + expect(privilegedSandboxExec).not.toHaveBeenCalled(); + }); + + it("refuses when shields are up", async () => { + getSandbox.mockReturnValue({ name: "alpha" }); + isShieldsDown.mockReturnValue(false); + await expect(runSandboxBrew("alpha", { kind: "init" })).rejects.toBeInstanceOf( + BrewCommandError, + ); + expect(privilegedSandboxExec).not.toHaveBeenCalled(); + }); + + it("is idempotent when brew is already initialised", async () => { + getSandbox.mockReturnValue({ name: "alpha", brewInitialised: true }); + isShieldsDown.mockReturnValue(true); + await runSandboxBrew("alpha", { kind: "init" }); + expect(privilegedSandboxExec).not.toHaveBeenCalled(); + expect(updateSandbox).not.toHaveBeenCalled(); + }); + + it("runs the bootstrap script as root and marks the sandbox initialised", async () => { + getSandbox.mockReturnValue({ name: "alpha" }); + isShieldsDown.mockReturnValue(true); + privilegedSandboxExec.mockReturnValue(""); + await runSandboxBrew("alpha", { kind: "init" }); + expect(privilegedSandboxExec).toHaveBeenCalledTimes(1); + const [name, cmd, opts] = privilegedSandboxExec.mock.calls[0] ?? fail("missing call"); + expect(name).toBe("alpha"); + expect(cmd).toEqual(["bash", "-s"]); + expect(opts?.input).toMatch(/useradd -m -s \/bin\/bash linuxbrew/); + expect(opts?.input).toContain("https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh"); + expect(opts?.input).toMatch(/\/etc\/profile\.d\/nemoclaw-linuxbrew\.sh/); + expect(updateSandbox).toHaveBeenCalledWith("alpha", { brewInitialised: true }); + }); + + it("fails when the registry write fails", async () => { + getSandbox.mockReturnValue({ name: "alpha" }); + isShieldsDown.mockReturnValue(true); + privilegedSandboxExec.mockReturnValue(""); + updateSandbox.mockReturnValue(false); + await expect(runSandboxBrew("alpha", { kind: "init" })).rejects.toBeInstanceOf( + BrewCommandError, + ); + }); + }); + + describe("install", () => { + it("refuses when brew is not initialised", async () => { + getSandbox.mockReturnValue({ name: "alpha" }); + isShieldsDown.mockReturnValue(true); + await expect( + runSandboxBrew("alpha", { kind: "install", packages: ["jq"] }), + ).rejects.toBeInstanceOf(BrewCommandError); + expect(privilegedSandboxExec).not.toHaveBeenCalled(); + }); + + it("refuses when shields are up", async () => { + getSandbox.mockReturnValue({ name: "alpha", brewInitialised: true }); + isShieldsDown.mockReturnValue(false); + await expect( + runSandboxBrew("alpha", { kind: "install", packages: ["jq"] }), + ).rejects.toBeInstanceOf(BrewCommandError); + expect(privilegedSandboxExec).not.toHaveBeenCalled(); + }); + + it("refuses when no packages are provided", async () => { + getSandbox.mockReturnValue({ name: "alpha", brewInitialised: true }); + isShieldsDown.mockReturnValue(true); + await expect( + runSandboxBrew("alpha", { kind: "install", packages: [] }), + ).rejects.toBeInstanceOf(BrewCommandError); + expect(privilegedSandboxExec).not.toHaveBeenCalled(); + }); + + it("refuses formulae that do not match the safe pattern", async () => { + getSandbox.mockReturnValue({ name: "alpha", brewInitialised: true }); + isShieldsDown.mockReturnValue(true); + await expect( + runSandboxBrew("alpha", { kind: "install", packages: ["jq;rm -rf /"] }), + ).rejects.toBeInstanceOf(BrewCommandError); + expect(privilegedSandboxExec).not.toHaveBeenCalled(); + }); + + it("invokes brew install as the linuxbrew user", async () => { + getSandbox.mockReturnValue({ name: "alpha", brewInitialised: true }); + isShieldsDown.mockReturnValue(true); + privilegedSandboxExec.mockReturnValue(""); + await runSandboxBrew("alpha", { kind: "install", packages: ["hello", "jq"] }); + expect(privilegedSandboxExec).toHaveBeenCalledTimes(1); + const [, cmd, opts] = privilegedSandboxExec.mock.calls[0] ?? fail("missing call"); + expect(cmd).toEqual([ + "/home/linuxbrew/.linuxbrew/bin/brew", + "install", + "hello", + "jq", + ]); + expect(opts?.user).toBe("linuxbrew"); + }); + + describe("auto-init when --yes + NEMOCLAW_NON_INTERACTIVE=1", () => { + let originalNonInteractive: string | undefined; + + beforeEach(() => { + originalNonInteractive = process.env.NEMOCLAW_NON_INTERACTIVE; + }); + + afterEach(() => { + if (originalNonInteractive === undefined) { + delete process.env.NEMOCLAW_NON_INTERACTIVE; + } else { + process.env.NEMOCLAW_NON_INTERACTIVE = originalNonInteractive; + } + }); + + it("auto-runs init then install when yes + non-interactive and brew is missing", async () => { + process.env.NEMOCLAW_NON_INTERACTIVE = "1"; + getSandbox.mockReturnValue({ name: "alpha" }); + isShieldsDown.mockReturnValue(true); + privilegedSandboxExec.mockReturnValue(""); + await runSandboxBrew("alpha", { kind: "install", packages: ["hello"], yes: true }); + expect(privilegedSandboxExec).toHaveBeenCalledTimes(2); + const initCall = privilegedSandboxExec.mock.calls[0] ?? fail("missing init call"); + const installCall = privilegedSandboxExec.mock.calls[1] ?? fail("missing install call"); + expect(initCall[1]).toEqual(["bash", "-s"]); + expect((initCall[2] as { input?: string })?.input).toMatch(/useradd -m -s \/bin\/bash linuxbrew/); + expect(installCall[1]).toEqual([ + "/home/linuxbrew/.linuxbrew/bin/brew", + "install", + "hello", + ]); + expect(updateSandbox).toHaveBeenCalledWith("alpha", { brewInitialised: true }); + }); + + it("refuses when --yes is set without NEMOCLAW_NON_INTERACTIVE", async () => { + delete process.env.NEMOCLAW_NON_INTERACTIVE; + getSandbox.mockReturnValue({ name: "alpha" }); + isShieldsDown.mockReturnValue(true); + await expect( + runSandboxBrew("alpha", { kind: "install", packages: ["hello"], yes: true }), + ).rejects.toBeInstanceOf(BrewCommandError); + expect(privilegedSandboxExec).not.toHaveBeenCalled(); + }); + + it("refuses when NEMOCLAW_NON_INTERACTIVE is set without --yes", async () => { + process.env.NEMOCLAW_NON_INTERACTIVE = "1"; + getSandbox.mockReturnValue({ name: "alpha" }); + isShieldsDown.mockReturnValue(true); + await expect( + runSandboxBrew("alpha", { kind: "install", packages: ["hello"] }), + ).rejects.toBeInstanceOf(BrewCommandError); + expect(privilegedSandboxExec).not.toHaveBeenCalled(); + }); + }); + }); + + describe("uninstall", () => { + it("invokes brew uninstall as the linuxbrew user", async () => { + getSandbox.mockReturnValue({ name: "alpha", brewInitialised: true }); + isShieldsDown.mockReturnValue(true); + privilegedSandboxExec.mockReturnValue(""); + await runSandboxBrew("alpha", { kind: "uninstall", packages: ["hello"] }); + const [, cmd, opts] = privilegedSandboxExec.mock.calls[0] ?? fail("missing call"); + expect(cmd).toEqual(["/home/linuxbrew/.linuxbrew/bin/brew", "uninstall", "hello"]); + expect(opts?.user).toBe("linuxbrew"); + }); + + it("refuses when brew is not initialised", async () => { + getSandbox.mockReturnValue({ name: "alpha" }); + isShieldsDown.mockReturnValue(true); + await expect( + runSandboxBrew("alpha", { kind: "uninstall", packages: ["hello"] }), + ).rejects.toBeInstanceOf(BrewCommandError); + expect(privilegedSandboxExec).not.toHaveBeenCalled(); + }); + }); + + describe("deinit", () => { + it("is idempotent when brew was never initialised", async () => { + getSandbox.mockReturnValue({ name: "alpha" }); + isShieldsDown.mockReturnValue(true); + await runSandboxBrew("alpha", { kind: "deinit" }); + expect(privilegedSandboxExec).not.toHaveBeenCalled(); + }); + + it("removes /home/linuxbrew, the profile.d hook, and clears the registry flag", async () => { + getSandbox.mockReturnValue({ name: "alpha", brewInitialised: true }); + isShieldsDown.mockReturnValue(true); + privilegedSandboxExec.mockReturnValue(""); + await runSandboxBrew("alpha", { kind: "deinit" }); + const [, cmd, opts] = privilegedSandboxExec.mock.calls[0] ?? fail("missing call"); + expect(cmd).toEqual(["bash", "-s"]); + expect(opts?.input).toMatch(/rm -f \/etc\/profile\.d\/nemoclaw-linuxbrew\.sh/); + expect(opts?.input).toMatch(/rm -rf \/home\/linuxbrew\/\.linuxbrew \/home\/linuxbrew/); + expect(opts?.input).toMatch(/userdel linuxbrew/); + expect(updateSandbox).toHaveBeenCalledWith("alpha", { brewInitialised: false }); + }); + }); + + describe("help", () => { + it("prints usage without privileged exec", async () => { + const log = vi.spyOn(console, "log"); + await runSandboxBrew("alpha", { kind: "help" }); + expect(privilegedSandboxExec).not.toHaveBeenCalled(); + const out = log.mock.calls.map((args) => args[0]).join("\n"); + expect(out).toMatch(/brew init/); + expect(out).toMatch(/brew install/); + }); + }); +}); diff --git a/src/lib/actions/sandbox/brew.ts b/src/lib/actions/sandbox/brew.ts new file mode 100644 index 00000000000..c2e2ee518a4 --- /dev/null +++ b/src/lib/actions/sandbox/brew.ts @@ -0,0 +1,297 @@ +// 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 { CLI_NAME } from "../../cli/branding"; +import { privilegedSandboxExec } from "../../adapters/sandbox/privileged-exec"; +import * as policy from "../../policy"; +import { ROOT, run, runCapture } from "../../runner"; +import { isShieldsDown } from "../../shields"; +import * as registry from "../../state/registry"; +import type { SandboxEntry } from "../../state/registry"; + +const LINUXBREW_PREFIX = "/home/linuxbrew/.linuxbrew"; +const BREW_BIN = `${LINUXBREW_PREFIX}/bin/brew`; +const PROFILE_D_PATH = "/etc/profile.d/nemoclaw-linuxbrew.sh"; +const FORMULA_PATTERN = /^[a-z0-9][a-z0-9._@/+-]*$/; +const HOMEBREW_INSTALL_URL = + "https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh"; +const BREW_INTEGRATION_POLICY = path.join( + ROOT, + "nemoclaw-blueprint", + "policies", + "integrations", + "brew.yaml", +); + +export type BrewRequest = + | { kind: "help" } + | { kind: "init" } + | { kind: "deinit" } + | { kind: "install"; packages: readonly string[]; yes?: boolean } + | { kind: "uninstall"; packages: readonly string[] }; + +export class BrewCommandError extends Error { + readonly lines: readonly string[]; + readonly exitCode: number; + + constructor(lines: string | readonly string[] = [], exitCode = 1) { + const normalized = Array.isArray(lines) ? lines : [lines]; + super(normalized.join("\n") || `Brew command failed with exit ${exitCode}`); + this.name = "BrewCommandError"; + this.lines = normalized; + this.exitCode = exitCode; + } +} + +function brewExit(exitCode = 1): never { + throw new BrewCommandError([], exitCode); +} + +function assertSandboxRegistered(name: string): SandboxEntry { + const entry = registry.getSandbox(name); + if (!entry) { + console.error(` Sandbox '${name}' is not registered.`); + brewExit(1); + } + return entry; +} + +function assertShieldsDown(name: string): void { + if (!isShieldsDown(name)) { + console.error(` Sandbox '${name}' has shields up. Run '${CLI_NAME} ${name} shields down' first.`); + brewExit(1); + } +} + +function assertBrewInitialised( + entry: SandboxEntry, + sandboxName: string, + extraHint?: string, +): void { + if (entry.brewInitialised !== true) { + console.error( + ` Homebrew is not installed in '${sandboxName}'. Run '${CLI_NAME} ${sandboxName} brew init' first.`, + ); + if (extraHint) console.error(` ${extraHint}`); + brewExit(1); + } +} + +function isNonInteractive(): boolean { + return process.env.NEMOCLAW_NON_INTERACTIVE === "1"; +} + +function readBrewIntegrationEntries(): string | null { + if (!fs.existsSync(BREW_INTEGRATION_POLICY)) return null; + const content = fs.readFileSync(BREW_INTEGRATION_POLICY, "utf-8"); + return policy.extractPresetEntries(content); +} + +function withTempPolicyFile(content: string, fn: (filePath: string) => T): T { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-brew-policy-")); + const filePath = path.join(dir, "policy.yaml"); + fs.writeFileSync(filePath, content, { encoding: "utf-8", mode: 0o600 }); + try { + return fn(filePath); + } finally { + try { + fs.unlinkSync(filePath); + } catch { + /* best effort */ + } + try { + fs.rmdirSync(dir); + } catch { + /* best effort */ + } + } +} + +function applyBrewRuntimePolicy(sandboxName: string): void { + const entries = readBrewIntegrationEntries(); + if (!entries) return; + const rawPolicy = runCapture(policy.buildPolicyGetCommand(sandboxName), { ignoreError: true }); + const current = policy.parseCurrentPolicy(rawPolicy); + const merged = policy.mergePresetIntoPolicy(current, entries); + withTempPolicyFile(merged, (filePath) => { + run(policy.buildPolicySetCommand(filePath, sandboxName)); + }); +} + +function removeBrewRuntimePolicy(sandboxName: string): void { + const entries = readBrewIntegrationEntries(); + if (!entries) return; + const rawPolicy = runCapture(policy.buildPolicyGetCommand(sandboxName), { ignoreError: true }); + const current = policy.parseCurrentPolicy(rawPolicy); + if (!current) return; + const updated = policy.removePresetFromPolicy(current, entries); + if (updated === current) return; + withTempPolicyFile(updated, (filePath) => { + run(policy.buildPolicySetCommand(filePath, sandboxName)); + }); +} + +function assertFormulae(packages: readonly string[]): void { + if (packages.length === 0) { + console.error(" No packages specified."); + brewExit(1); + } + const invalid = packages.filter((p) => !FORMULA_PATTERN.test(p)); + if (invalid.length > 0) { + console.error(` Invalid formula name(s): ${invalid.join(", ")}`); + brewExit(1); + } +} + +function printHelp(sandboxName: string): void { + console.log(` Usage:`); + console.log(` ${CLI_NAME} ${sandboxName} brew init Bootstrap Homebrew (Linuxbrew) in the sandbox`); + console.log(` ${CLI_NAME} ${sandboxName} brew install ... [--yes] Install one or more formulae (--yes + NEMOCLAW_NON_INTERACTIVE=1 auto-runs init)`); + console.log(` ${CLI_NAME} ${sandboxName} brew uninstall ... Uninstall one or more formulae`); + console.log(` ${CLI_NAME} ${sandboxName} brew deinit Remove Homebrew from the sandbox`); +} + +function brewInitScript(): string { + return [ + "set -euo pipefail", + `if ! id linuxbrew >/dev/null 2>&1; then`, + " useradd -m -s /bin/bash linuxbrew", + "fi", + `mkdir -p ${LINUXBREW_PREFIX}`, + `chown -R linuxbrew:linuxbrew /home/linuxbrew`, + `runuser -u linuxbrew -- env NONINTERACTIVE=1 /bin/bash -c '/bin/bash -c "$(curl -fsSL ${HOMEBREW_INSTALL_URL})"'`, + `test -x ${BREW_BIN}`, + `cat >${PROFILE_D_PATH} <<'EOF'`, + `# NemoClaw: expose Homebrew (Linuxbrew) to interactive shells (#3757)`, + `if [ -d ${LINUXBREW_PREFIX}/bin ]; then`, + ` case ":\${PATH}:" in`, + ` *":${LINUXBREW_PREFIX}/bin:"*) ;;`, + ` *) PATH="${LINUXBREW_PREFIX}/bin:\${PATH}"; export PATH ;;`, + ` esac`, + `fi`, + `EOF`, + `chmod 444 ${PROFILE_D_PATH}`, + ].join("\n"); +} + +function brewDeinitScript(): string { + return [ + "set -eu", + `rm -f ${PROFILE_D_PATH}`, + `rm -rf ${LINUXBREW_PREFIX} /home/linuxbrew`, + `userdel linuxbrew 2>/dev/null || true`, + ].join("\n"); +} + +function runInit(sandboxName: string): void { + const entry = assertSandboxRegistered(sandboxName); + assertShieldsDown(sandboxName); + if (entry.brewInitialised === true) { + console.log(` Homebrew is already installed in '${sandboxName}'.`); + return; + } + console.log(` Bootstrapping Homebrew in '${sandboxName}' (this can take several minutes)...`); + privilegedSandboxExec(sandboxName, ["bash", "-s"], { + input: brewInitScript(), + timeout: 900_000, + }); + console.log(` Authorising Homebrew binaries on the sandbox gateway policy...`); + applyBrewRuntimePolicy(sandboxName); + if (!registry.updateSandbox(sandboxName, { brewInitialised: true })) { + console.error(` Failed to persist Homebrew state for '${sandboxName}'.`); + brewExit(1); + } + console.log(` Homebrew installed at ${LINUXBREW_PREFIX}.`); + console.log(` Install formulae with: ${CLI_NAME} ${sandboxName} brew install ...`); +} + +function runInstall( + sandboxName: string, + packages: readonly string[], + yes: boolean, +): void { + const entry = assertSandboxRegistered(sandboxName); + assertShieldsDown(sandboxName); + assertFormulae(packages); + if (entry.brewInitialised !== true) { + if (yes && isNonInteractive()) { + console.log( + ` Homebrew is not installed in '${sandboxName}'. --yes + NEMOCLAW_NON_INTERACTIVE=1 set; auto-initialising.`, + ); + runInit(sandboxName); + } else { + assertBrewInitialised( + entry, + sandboxName, + "Or pass --yes with NEMOCLAW_NON_INTERACTIVE=1 to auto-initialise before install.", + ); + } + } + console.log(` Installing ${packages.length} formula(e) into '${sandboxName}': ${packages.join(", ")}`); + privilegedSandboxExec(sandboxName, [BREW_BIN, "install", ...packages], { + user: "linuxbrew", + timeout: 900_000, + }); + console.log(` Done.`); +} + +function runUninstall(sandboxName: string, packages: readonly string[]): void { + const entry = assertSandboxRegistered(sandboxName); + assertShieldsDown(sandboxName); + assertBrewInitialised(entry, sandboxName); + assertFormulae(packages); + console.log(` Uninstalling ${packages.length} formula(e) from '${sandboxName}': ${packages.join(", ")}`); + privilegedSandboxExec(sandboxName, [BREW_BIN, "uninstall", ...packages], { + user: "linuxbrew", + timeout: 300_000, + }); + console.log(` Done.`); +} + +function runDeinit(sandboxName: string): void { + const entry = assertSandboxRegistered(sandboxName); + assertShieldsDown(sandboxName); + if (entry.brewInitialised !== true) { + console.log(` Homebrew is not installed in '${sandboxName}'; nothing to deinit.`); + return; + } + console.log(` Removing Homebrew from '${sandboxName}'...`); + privilegedSandboxExec(sandboxName, ["bash", "-s"], { + input: brewDeinitScript(), + timeout: 180_000, + }); + console.log(` Removing Homebrew gateway-policy entry...`); + removeBrewRuntimePolicy(sandboxName); + if (!registry.updateSandbox(sandboxName, { brewInitialised: false })) { + console.error(` Failed to persist Homebrew state for '${sandboxName}'.`); + brewExit(1); + } + console.log(` Homebrew removed.`); +} + +export async function runSandboxBrew( + sandboxName: string, + request: BrewRequest = { kind: "help" }, +): Promise { + switch (request.kind) { + case "help": + printHelp(sandboxName); + return; + case "init": + runInit(sandboxName); + return; + case "deinit": + runDeinit(sandboxName); + return; + case "install": + runInstall(sandboxName, request.packages, request.yes === true); + return; + case "uninstall": + runUninstall(sandboxName, request.packages); + return; + } +} diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index 686cd2942a2..0bacede6d4c 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -219,6 +219,7 @@ async function applyExternalPreset( } export function listSandboxPolicies(sandboxName: string) { + policies.pruneStaleBuiltInPresets(sandboxName); const builtin = policies.listPresets(); const custom = policies.listCustomPresets(sandboxName); const allPresets = [...builtin, ...custom]; diff --git a/src/lib/actions/sandbox/rebuild.ts b/src/lib/actions/sandbox/rebuild.ts index 5fca56ce188..a71dc6440e5 100644 --- a/src/lib/actions/sandbox/rebuild.ts +++ b/src/lib/actions/sandbox/rebuild.ts @@ -755,8 +755,25 @@ export async function rebuildSandbox( // Step 5.5: Restore policy presets (#1952) // Policy presets live in the gateway policy engine, not the sandbox filesystem. // They are lost when the sandbox is destroyed and recreated. Re-apply any - // presets that were captured in the backup manifest. - const savedPresets = backupManifest.policyPresets || []; + // presets that were captured in the backup manifest, after filtering out + // any built-in preset names that have since been removed from the + // blueprint (e.g. the legacy `brew` preset dropped in #3757). + const savedPresetsRaw = backupManifest.policyPresets || []; + const customNames = registry + .getCustomPolicies(sandboxName) + .map((p: { name: string }) => p.name); + const { known: savedPresets, stale: stalePresets } = policies.partitionKnownPresetNames( + savedPresetsRaw, + customNames, + ); + if (stalePresets.length > 0) { + console.warn( + ` Warning: skipping ${stalePresets.length} stale preset(s) from backup manifest: ${stalePresets.join(", ")}.`, + ); + console.warn( + " These presets are no longer defined in the blueprint.", + ); + } if (savedPresets.length > 0) { console.log(""); console.log(" Restoring policy presets..."); diff --git a/src/lib/actions/sandbox/snapshot.ts b/src/lib/actions/sandbox/snapshot.ts index 86f4314b6de..807e6920f7a 100644 --- a/src/lib/actions/sandbox/snapshot.ts +++ b/src/lib/actions/sandbox/snapshot.ts @@ -200,6 +200,11 @@ async function autoCreateSandboxFromSource( // dst has its own lifecycle; don't inherit src's local NIM container // reference, or destroying dst would stop src's NIM. nimContainer: null, + // Sandbox-filesystem-bound state must not be inherited: snapshot only + // covers agent-declared state dirs/files, not /home/linuxbrew. The dst + // image ships without a Homebrew prefix until the operator runs + // `nemoclaw brew init` (#3757). + brewInitialised: false, }); console.log(` ${G}\u2713${R} Sandbox '${dstName}' created`); diff --git a/src/lib/adapters/sandbox/privileged-exec.test.ts b/src/lib/adapters/sandbox/privileged-exec.test.ts new file mode 100644 index 00000000000..3d018cc122b --- /dev/null +++ b/src/lib/adapters/sandbox/privileged-exec.test.ts @@ -0,0 +1,222 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const getSandbox = vi.hoisted(() => vi.fn()); +const dockerCapture = vi.hoisted(() => vi.fn()); +const dockerExecFileSync = vi.hoisted(() => vi.fn()); + +vi.mock("../../state/registry", () => ({ + getSandbox, +})); + +vi.mock("../docker/run", () => ({ + dockerCapture, +})); + +vi.mock("../docker/exec", () => ({ + dockerExecFileSync, +})); + +import { + K3S_CONTAINER, + OPENSHELL_SANDBOX_NAME_LABEL, + kubectlExecArgv, + privilegedSandboxExec, + privilegedSandboxExecArgv, + resolveDockerDriverSandboxContainer, + selectDockerDriverSandboxContainer, + selectLabelledSandboxContainer, +} from "./privileged-exec"; + +describe("selectDockerDriverSandboxContainer", () => { + it("returns the exact docker-driver sandbox container when present", () => { + expect( + selectDockerDriverSandboxContainer("demo", "docker", "openshell-demo\nopenshell-other"), + ).toBe("openshell-demo"); + }); + + it("does not return a prefix-matching container — that would risk cross-sandbox routing", () => { + expect( + selectDockerDriverSandboxContainer("demo", "docker", "openshell-other\nopenshell-demo-abc"), + ).toBeNull(); + }); + + it("returns null for the legacy kubernetes driver", () => { + expect( + selectDockerDriverSandboxContainer("demo", "kubernetes", "openshell-demo\n"), + ).toBeNull(); + }); + + it("returns null when no container matches", () => { + expect(selectDockerDriverSandboxContainer("demo", "docker", "openshell-other\n")).toBeNull(); + }); +}); + +describe("selectLabelledSandboxContainer", () => { + it("returns the canonical container when present", () => { + expect( + selectLabelledSandboxContainer("demo", "openshell-demo-sbx-abc\nopenshell-demo"), + ).toBe("openshell-demo"); + }); + + it("falls back to the first labelled container when canonical name is absent", () => { + expect(selectLabelledSandboxContainer("demo", "openshell-demo-sbx-abc")).toBe( + "openshell-demo-sbx-abc", + ); + }); + + it("returns null when no containers are labelled", () => { + expect(selectLabelledSandboxContainer("demo", "")).toBeNull(); + }); +}); + +describe("resolveDockerDriverSandboxContainer", () => { + beforeEach(() => { + getSandbox.mockReset(); + dockerCapture.mockReset(); + }); + + it("queries by label first and returns the labelled match", () => { + getSandbox.mockReturnValue({ openshellDriver: "docker" }); + dockerCapture.mockImplementation((args: readonly string[]) => { + if (args.includes("--filter")) return "openshell-demo-sbx-abc\n"; + return ""; + }); + expect(resolveDockerDriverSandboxContainer("demo")).toBe("openshell-demo-sbx-abc"); + const labelCall = dockerCapture.mock.calls.find((args) => + (args[0] as readonly string[]).includes("--filter"), + ); + expect(labelCall?.[0]).toContain(`label=${OPENSHELL_SANDBOX_NAME_LABEL}=demo`); + }); + + it("falls back to an exact name match for pre-label sandboxes", () => { + getSandbox.mockReturnValue({ openshellDriver: "docker" }); + dockerCapture.mockImplementation((args: readonly string[]) => { + if (args.includes("--filter")) return ""; + return "openshell-other\nopenshell-demo\n"; + }); + expect(resolveDockerDriverSandboxContainer("demo")).toBe("openshell-demo"); + }); + + it("does NOT misroute sandbox `demo` to sandbox `demo-prod`'s suffixed container", () => { + getSandbox.mockReturnValue({ openshellDriver: "docker" }); + dockerCapture.mockImplementation((args: readonly string[]) => { + if (args.includes("--filter")) return ""; + return "openshell-demo-prod-sbx-abc\n"; + }); + expect(resolveDockerDriverSandboxContainer("demo")).toBeNull(); + }); + + it("returns null for non-docker drivers without calling docker ps", () => { + getSandbox.mockReturnValue({ openshellDriver: "kubernetes" }); + expect(resolveDockerDriverSandboxContainer("demo")).toBeNull(); + expect(dockerCapture).not.toHaveBeenCalled(); + }); +}); + +describe("kubectlExecArgv", () => { + it("builds a kubectl exec via the K3s container", () => { + expect(kubectlExecArgv("alpha", ["whoami"])).toEqual([ + "exec", + K3S_CONTAINER, + "kubectl", + "exec", + "-n", + "openshell", + "alpha", + "-c", + "agent", + "--", + "whoami", + ]); + }); + + it("wraps non-root users with runuser", () => { + expect(kubectlExecArgv("alpha", ["whoami"], { user: "linuxbrew" })).toEqual([ + "exec", + K3S_CONTAINER, + "kubectl", + "exec", + "-n", + "openshell", + "alpha", + "-c", + "agent", + "--", + "runuser", + "-u", + "linuxbrew", + "--", + "whoami", + ]); + }); + + it("threads -i for stdin", () => { + const argv = kubectlExecArgv("alpha", ["bash", "-s"], { stdin: true }); + expect(argv.filter((s) => s === "-i")).toHaveLength(2); + }); +}); + +describe("privilegedSandboxExecArgv", () => { + beforeEach(() => { + getSandbox.mockReset(); + dockerCapture.mockReset(); + }); + + it("runs as the container's USER (root) when the driver is docker", () => { + getSandbox.mockReturnValue({ openshellDriver: "docker" }); + dockerCapture.mockReturnValue("openshell-alpha\n"); + expect(privilegedSandboxExecArgv("alpha", ["whoami"])).toEqual([ + "exec", + "openshell-alpha", + "whoami", + ]); + }); + + it("falls back to kubectl exec when no docker-driver container resolves", () => { + getSandbox.mockReturnValue({ openshellDriver: "kubernetes" }); + const argv = privilegedSandboxExecArgv("alpha", ["whoami"]); + expect(argv[0]).toBe("exec"); + expect(argv).toContain(K3S_CONTAINER); + expect(argv).toContain("kubectl"); + }); + + it("wraps non-root users with runuser so HOME is set to the target user's home", () => { + getSandbox.mockReturnValue({ openshellDriver: "docker" }); + dockerCapture.mockReturnValue("openshell-alpha\n"); + expect( + privilegedSandboxExecArgv("alpha", ["brew", "install", "hello"], { user: "linuxbrew" }), + ).toEqual([ + "exec", + "openshell-alpha", + "runuser", + "-u", + "linuxbrew", + "--", + "brew", + "install", + "hello", + ]); + }); +}); + +describe("privilegedSandboxExec", () => { + beforeEach(() => { + getSandbox.mockReset(); + dockerCapture.mockReset(); + dockerExecFileSync.mockReset(); + }); + + it("forwards input as stdin and returns the captured output", () => { + getSandbox.mockReturnValue({ openshellDriver: "docker" }); + dockerCapture.mockReturnValue("openshell-alpha\n"); + dockerExecFileSync.mockReturnValue("ok\n"); + const out = privilegedSandboxExec("alpha", ["bash", "-s"], { input: "echo ok" }); + expect(out).toBe("ok\n"); + const [argv, opts] = dockerExecFileSync.mock.calls[0] ?? []; + expect(argv).toContain("-i"); + expect((opts as { input?: string }).input).toBe("echo ok"); + }); +}); diff --git a/src/lib/adapters/sandbox/privileged-exec.ts b/src/lib/adapters/sandbox/privileged-exec.ts new file mode 100644 index 00000000000..c4ee5412a28 --- /dev/null +++ b/src/lib/adapters/sandbox/privileged-exec.ts @@ -0,0 +1,165 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { dockerExecFileSync } from "../docker/exec"; +import { dockerCapture } from "../docker/run"; +import * as registry from "../../state/registry"; + +export const K3S_CONTAINER = "openshell-cluster-nemoclaw"; + +export interface PrivilegedSandboxExecArgvOptions { + stdin?: boolean; + user?: string; +} + +export interface PrivilegedSandboxExecOptions extends PrivilegedSandboxExecArgvOptions { + input?: string | Buffer; + timeout?: number; +} + +export const OPENSHELL_SANDBOX_NAME_LABEL = "openshell.ai/sandbox-name"; + +/** + * Resolve a Docker-driver container by exact name only. The container that + * OpenShell labels with `openshell.ai/sandbox-name=` lives under the + * canonical name `openshell-`. Name-prefix inference is not safe — a + * container named `openshell--` shares the prefix of + * sandbox `` even when it belongs to sandbox `-`. + * The label-aware live path in `resolveDockerDriverSandboxContainer` handles + * the version of OpenShell that tags containers; this helper exists for + * tests and for the compat path where no labelled match was found. + */ +export function selectDockerDriverSandboxContainer( + sandboxName: string, + openshellDriver: string | null | undefined, + containerNames: string, +): string | null { + if (openshellDriver !== "docker") return null; + const exact = `openshell-${sandboxName}`; + const match = containerNames + .split("\n") + .map((line) => line.trim()) + .find((name) => name === exact); + return match ?? null; +} + +/** + * Pick the canonical container for `sandboxName` from a label-filtered + * `docker ps` output. When several containers share the label (e.g. helper + * containers), prefer the canonical `openshell-`; otherwise the first + * one. + */ +export function selectLabelledSandboxContainer( + sandboxName: string, + containerNames: string, +): string | null { + const trimmed = containerNames + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + if (trimmed.length === 0) return null; + const exact = `openshell-${sandboxName}`; + return trimmed.find((name) => name === exact) ?? trimmed[0] ?? null; +} + +export function resolveDockerDriverSandboxContainer(sandboxName: string): string | null { + let openshellDriver: string | null | undefined; + try { + openshellDriver = registry.getSandbox?.(sandboxName)?.openshellDriver; + } catch { + return null; + } + if (openshellDriver !== "docker") return null; + + const labelled = dockerCapture( + [ + "ps", + "--filter", + `label=${OPENSHELL_SANDBOX_NAME_LABEL}=${sandboxName}`, + "--format", + "{{.Names}}", + ], + { ignoreError: true }, + ); + const labelMatch = selectLabelledSandboxContainer(sandboxName, labelled); + if (labelMatch) return labelMatch; + + // Fallback: older OpenShell sandboxes (pre-label) only get the exact + // canonical name. We deliberately do not fall back to a name-prefix + // match because the OpenShell Docker driver names containers + // `openshell--`, and that suffix can encode + // another sandbox's name (sandbox `demo-prod` lives under + // `openshell-demo-prod-`, which would otherwise be accepted as a + // container for sandbox `demo`). + const output = dockerCapture(["ps", "--format", "{{.Names}}"], { ignoreError: true }); + return selectDockerDriverSandboxContainer(sandboxName, openshellDriver, output); +} + +function withUserPrefix(cmd: readonly string[], user: string): string[] { + if (user === "root") return [...cmd]; + return ["runuser", "-u", user, "--", ...cmd]; +} + +export function kubectlExecArgv( + sandboxName: string, + cmd: readonly string[], + options: PrivilegedSandboxExecArgvOptions = {}, +): string[] { + const { stdin = false, user = "root" } = options; + return [ + "exec", + ...(stdin ? ["-i"] : []), + K3S_CONTAINER, + "kubectl", + "exec", + "-n", + "openshell", + sandboxName, + "-c", + "agent", + ...(stdin ? ["-i"] : []), + "--", + ...withUserPrefix(cmd, user), + ]; +} + +export function privilegedSandboxExecArgv( + sandboxName: string, + cmd: readonly string[], + options: PrivilegedSandboxExecArgvOptions = {}, +): string[] { + const { stdin = false, user = "root" } = options; + const dockerDriverContainer = resolveDockerDriverSandboxContainer(sandboxName); + if (dockerDriverContainer) { + // Don't use docker's `--user` flag for non-root targets — it switches + // UID but inherits HOME from the calling shell, which causes tools + // like Homebrew to write to /root/.cache and trip EACCES. `runuser` + // (already used on the kubectl path) sets HOME to the target user's + // home, so we get a clean per-user environment. + return [ + "exec", + ...(stdin ? ["-i"] : []), + dockerDriverContainer, + ...withUserPrefix(cmd, user), + ]; + } + return kubectlExecArgv(sandboxName, cmd, { stdin, user }); +} + +export function privilegedSandboxExec( + sandboxName: string, + cmd: readonly string[], + options: PrivilegedSandboxExecOptions = {}, +): string { + const { input, timeout = 30000, user, stdin: stdinFlag } = options; + const hasInput = input !== undefined; + const argv = privilegedSandboxExecArgv(sandboxName, cmd, { + stdin: stdinFlag ?? hasInput, + user, + }); + return dockerExecFileSync(argv, { + input, + stdio: hasInput ? ["pipe", "pipe", "pipe"] : ["ignore", "pipe", "pipe"], + timeout, + }); +} diff --git a/src/lib/cli/command-registry.test.ts b/src/lib/cli/command-registry.test.ts index 01b96614253..36108a5add5 100644 --- a/src/lib/cli/command-registry.test.ts +++ b/src/lib/cli/command-registry.test.ts @@ -17,10 +17,10 @@ import { getRegisteredOclifCommandsMetadata } from "./oclif-metadata"; describe("command-registry", () => { describe("COMMANDS array", () => { - it("should contain exactly 60 commands", () => { + it("should contain exactly 64 commands", () => { // 27 global (21 visible + 6 hidden help/version aliases) - // 33 sandbox (27 visible + 6 hidden shields/config) - expect(COMMANDS).toHaveLength(60); + // 37 sandbox (31 visible + 6 hidden shields/config) + expect(COMMANDS).toHaveLength(64); }); it("should have no duplicate usage strings", () => { @@ -52,9 +52,9 @@ describe("command-registry", () => { }); describe("sandboxCommands()", () => { - it("should return exactly 33 entries", () => { - // 27 visible + 6 hidden (shields×3 + config get/set/rotate-token) - expect(sandboxCommands()).toHaveLength(33); + it("should return exactly 37 entries", () => { + // 31 visible + 6 hidden (shields×3 + config get/set/rotate-token) + expect(sandboxCommands()).toHaveLength(37); }); it("every entry has scope sandbox", () => { @@ -65,10 +65,10 @@ describe("command-registry", () => { }); describe("visibleCommands()", () => { - it("should exclude 12 hidden commands (48 visible)", () => { + it("should exclude 12 hidden commands (52 visible)", () => { // 6 hidden global (help, --help, -h, version, --version, -v) + // 6 hidden sandbox (shields×3, config get/set/rotate-token) - expect(visibleCommands()).toHaveLength(48); + expect(visibleCommands()).toHaveLength(52); }); it("no visible command has hidden=true", () => { @@ -203,9 +203,9 @@ describe("command-registry", () => { }); describe("sandboxActionTokens()", () => { - it("returns exactly 22 unique action tokens including empty string", () => { + it("returns exactly 23 unique action tokens including empty string", () => { const tokens = sandboxActionTokens(); - expect(tokens).toHaveLength(22); + expect(tokens).toHaveLength(23); // Must contain every first-level sandbox action plus the empty default action. const expected = new Set([ "connect", @@ -229,6 +229,7 @@ describe("command-registry", () => { "config", "channels", "gateway-token", + "brew", "", ]); expect(new Set(tokens)).toEqual(expected); diff --git a/src/lib/policy/index.ts b/src/lib/policy/index.ts index 9b6653ebd18..956494e1177 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -864,6 +864,59 @@ function loadPresetFromFile(filePath: string): { presetName: string; content: st return { presetName, content }; } +/** + * Partition a list of preset names into ones still defined as built-in or + * sandbox-local custom presets vs. ones whose definition has been removed + * (e.g. a built-in preset deleted between NemoClaw versions). + */ +function partitionKnownPresetNames( + presetNames: readonly string[], + customNames: readonly string[], +): { known: string[]; stale: string[] } { + const builtInSet = new Set(listPresets().map((p) => p.name)); + const customSet = new Set(customNames); + const known: string[] = []; + const stale: string[] = []; + for (const name of presetNames) { + if (builtInSet.has(name) || customSet.has(name)) known.push(name); + else stale.push(name); + } + return { known, stale }; +} + +/** + * Drop policy names from the sandbox registry whose underlying preset + * definition no longer exists. Custom presets (`customPolicies`) are + * exempt; only built-in names that have vanished from + * `nemoclaw-blueprint/policies/presets/` get pruned. Warns to stderr and + * persists the cleaned list, so the warning only fires once per sandbox. + * Returns the pruned names for callers that want to surface them further. + */ +function pruneStaleBuiltInPresets(sandboxName: string): string[] { + const sandbox = registry.getSandbox(sandboxName); + if (!sandbox) return []; + const policies = sandbox.policies || []; + if (policies.length === 0) return []; + + const customNames = (sandbox.customPolicies || []).map( + (p: { name: string }) => p.name, + ); + const { known, stale } = partitionKnownPresetNames(policies, customNames); + if (stale.length === 0) return []; + + console.warn( + ` Warning: dropping stale preset(s) from sandbox '${sandboxName}' registry: ${stale.join(", ")}.`, + ); + console.warn( + " These presets are no longer defined in the blueprint; the registry has been cleaned.", + ); + console.warn( + ` Note: any network rules these presets installed on the gateway may still be live. Run 'nemoclaw ${sandboxName} rebuild' to get a clean policy state.`, + ); + registry.updateSandbox(sandboxName, { policies: known }); + return stale; +} + /** * Return the list of preset names currently recorded as applied to the * sandbox (both built-in names and custom-preset names), or an empty array @@ -1094,6 +1147,8 @@ export { applyPermissivePolicy, resolvePermissivePolicyPath, getAppliedPresets, + partitionKnownPresetNames, + pruneStaleBuiltInPresets, getGatewayPresets, listCustomPresets, selectFromList, diff --git a/src/lib/sandbox/brew-command-support.ts b/src/lib/sandbox/brew-command-support.ts new file mode 100644 index 00000000000..8cd5b776618 --- /dev/null +++ b/src/lib/sandbox/brew-command-support.ts @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Args } from "@oclif/core"; + +type BrewCommandFailure = Error & { + exitCode: number; + lines: readonly string[]; +}; + +export function brewCommandError(error: unknown): BrewCommandFailure | null { + if (!error || typeof error !== "object") return null; + const candidate = error as Partial; + if ( + candidate.name === "BrewCommandError" && + typeof candidate.exitCode === "number" && + Array.isArray(candidate.lines) + ) { + return candidate as BrewCommandFailure; + } + return null; +} + +export const sandboxNameArg = Args.string({ + name: "sandbox", + description: "Sandbox name", + required: true, +}); diff --git a/src/lib/sandbox/config.ts b/src/lib/sandbox/config.ts index de4ee8c9fcc..c091e28ad75 100644 --- a/src/lib/sandbox/config.ts +++ b/src/lib/sandbox/config.ts @@ -20,15 +20,15 @@ const { promises: dnsPromises } = require("node:dns"); const { isIP } = require("node:net"); const { validateName } = require("../runner"); const { shellQuote } = require("../core/shell-quote"); -const { dockerExecFileSync } = require("../adapters/docker/exec"); -const { dockerCapture } = require("../adapters/docker/run"); const credentialFilter: typeof import("../security/credential-filter") = require("../security/credential-filter"); const { stripCredentials, isConfigObject, isConfigValue, isCredentialField } = credentialFilter; const { appendAuditEntry } = require("../shields/audit"); const { isPrivateHostname, isPrivateIp } = require("../private-networks"); -const registry = require("../state/registry") as { - getSandbox?: (name: string) => { openshellDriver?: string | null } | null; -}; +const { + selectDockerDriverSandboxContainer, + privilegedSandboxExecArgv, + privilegedSandboxExec: rawPrivilegedSandboxExec, +} = require("../adapters/sandbox/privileged-exec"); type ConfigObject = import("../security/credential-filter").ConfigObject; type ConfigValue = import("../security/credential-filter").ConfigValue; @@ -38,8 +38,6 @@ function parseJson(text: string): T { return JSON.parse(text); } -const K3S_CONTAINER = "openshell-cluster-nemoclaw"; - // --------------------------------------------------------------------------- // Agent-aware config resolution // @@ -116,75 +114,12 @@ const DEFAULT_AGENT_CONFIG: AgentConfigTarget = { const HERMES_STRICT_HASH_FILE = "/etc/nemoclaw/hermes.config-hash"; -// Privileged sandbox exec bypasses the sandbox process's Landlock domain for -// host-initiated config writes. Legacy OpenShell gateways expose the pod via -// K3s/kubectl; Docker-driver gateways expose a sandbox container directly. -function selectDockerDriverSandboxContainer( - sandboxName: string, - openshellDriver: string | null | undefined, - containerNames: string, -): string | null { - if (openshellDriver !== "docker") return null; - const prefix = `openshell-${sandboxName}-`; - const exact = `openshell-${sandboxName}`; - return ( - containerNames - .split("\n") - .map((line: string) => line.trim()) - .find((name: string) => name === exact || name.startsWith(prefix)) || null - ); -} - -function resolveDockerDriverSandboxContainer(sandboxName: string): string | null { - let openshellDriver: string | null | undefined; - try { - openshellDriver = registry.getSandbox?.(sandboxName)?.openshellDriver; - } catch { - return null; - } - - const output = dockerCapture(["ps", "--format", "{{.Names}}"], { ignoreError: true }); - return selectDockerDriverSandboxContainer(sandboxName, openshellDriver, output); -} - -function kubectlExecArgv(sandboxName: string, cmd: string[], stdin = false): string[] { - const args = [ - "exec", - ...(stdin ? ["-i"] : []), - K3S_CONTAINER, - "kubectl", - "exec", - "-n", - "openshell", - sandboxName, - "-c", - "agent", - ...(stdin ? ["-i"] : []), - "--", - ...cmd, - ]; - return args; -} - -function privilegedSandboxExecArgv(sandboxName: string, cmd: string[], stdin = false): string[] { - const dockerDriverContainer = resolveDockerDriverSandboxContainer(sandboxName); - if (dockerDriverContainer) { - return ["exec", ...(stdin ? ["-i"] : []), "--user", "root", dockerDriverContainer, ...cmd]; - } - return kubectlExecArgv(sandboxName, cmd, stdin); -} - function privilegedSandboxExec( sandboxName: string, cmd: string[], opts: { input?: string | Buffer; timeout?: number } = {}, ): string { - const hasInput = opts.input !== undefined; - return dockerExecFileSync(privilegedSandboxExecArgv(sandboxName, cmd, hasInput), { - input: opts.input, - stdio: hasInput ? ["pipe", "pipe", "pipe"] : ["ignore", "pipe", "pipe"], - timeout: opts.timeout ?? 30000, - }); + return rawPrivilegedSandboxExec(sandboxName, cmd, opts); } function resolveAgentConfig(sandboxName: string): AgentConfigTarget { diff --git a/src/lib/shields/index.ts b/src/lib/shields/index.ts index 9f8a7e315a4..9c34332d2e9 100644 --- a/src/lib/shields/index.ts +++ b/src/lib/shields/index.ts @@ -15,11 +15,9 @@ const path = require("path"); const { fork } = require("child_process"); const { randomBytes } = require("crypto"); const { run, runCapture, validateName } = require("../runner"); -const { dockerExecFileSync } = require("../adapters/docker/exec"); -const { dockerCapture } = require("../adapters/docker/run"); -const registry = require("../state/registry") as { - getSandbox?: (name: string) => { openshellDriver?: string | null } | null; -}; +const { + privilegedSandboxExec: rawPrivilegedSandboxExec, +} = require("../adapters/sandbox/privileged-exec"); const { buildPolicyGetCommand, buildPolicySetCommand, @@ -45,85 +43,15 @@ const { resolveAgentConfig } = require("../sandbox/config"); const STATE_DIR = resolveNemoclawStateDir(); -// --------------------------------------------------------------------------- -// privileged sandbox exec — bypasses the sandbox's Landlock context -// -// openshell sandbox exec runs commands INSIDE the Landlock domain, so it -// can't modify read_only paths or change chattr flags. kubectl exec starts -// a new process in the pod that does NOT inherit the Landlock ruleset. -// On the legacy gateway we reach kubectl via the K3s container. On the -// Docker-driver gateway there is no K3s container, so we exec into the -// sandbox Docker container directly as root. -// --------------------------------------------------------------------------- - -const K3S_CONTAINER = "openshell-cluster-nemoclaw"; - -function resolveDockerDriverSandboxContainer( - sandboxName: string, -): string | null { - try { - if (registry.getSandbox?.(sandboxName)?.openshellDriver !== "docker") { - return null; - } - } catch { - return null; - } - const prefix = `openshell-${sandboxName}-`; - const exact = `openshell-${sandboxName}`; - const output = dockerCapture(["ps", "--format", "{{.Names}}"], { - ignoreError: true, - }); - return ( - output - .split("\n") - .map((line: string) => line.trim()) - .find((name: string) => name === exact || name.startsWith(prefix)) || null - ); -} - -function kubectlExecArgv(sandboxName: string, cmd: string[]): string[] { - return [ - "exec", - K3S_CONTAINER, - "kubectl", - "exec", - "-n", - "openshell", - sandboxName, - "-c", - "agent", - "--", - ...cmd, - ]; -} - -function privilegedSandboxExecArgv( - sandboxName: string, - cmd: string[], -): string[] { - const dockerDriverContainer = - resolveDockerDriverSandboxContainer(sandboxName); - if (dockerDriverContainer) { - return ["exec", "--user", "root", dockerDriverContainer, ...cmd]; - } - return kubectlExecArgv(sandboxName, cmd); -} - function privilegedSandboxExec(sandboxName: string, cmd: string[]): void { - dockerExecFileSync(privilegedSandboxExecArgv(sandboxName, cmd), { - stdio: ["ignore", "pipe", "pipe"], - timeout: 15000, - }); + rawPrivilegedSandboxExec(sandboxName, cmd, { timeout: 15000 }); } function privilegedSandboxExecCapture( sandboxName: string, cmd: string[], ): string { - return dockerExecFileSync(privilegedSandboxExecArgv(sandboxName, cmd), { - stdio: ["ignore", "pipe", "pipe"], - timeout: 15000, - }).trim(); + return rawPrivilegedSandboxExec(sandboxName, cmd, { timeout: 15000 }).trim(); } // Re-export for tests and external consumers diff --git a/src/lib/state/registry.ts b/src/lib/state/registry.ts index 548859d436e..d5bda4bc4cf 100644 --- a/src/lib/state/registry.ts +++ b/src/lib/state/registry.ts @@ -40,6 +40,7 @@ export interface SandboxEntry { hermesToolGateways?: string[]; disabledChannels?: string[]; dashboardPort?: number | null; + brewInitialised?: boolean; } export interface SandboxRegistry { @@ -224,6 +225,7 @@ export function registerSandbox(entry: SandboxEntry): void { ? [...entry.disabledChannels] : undefined, dashboardPort: entry.dashboardPort ?? undefined, + brewInitialised: entry.brewInitialised === true ? true : undefined, }; if (!data.defaultSandbox) { data.defaultSandbox = entry.name; diff --git a/test/config-set.test.ts b/test/config-set.test.ts index 5aa24f9e0c3..564141f4b64 100644 --- a/test/config-set.test.ts +++ b/test/config-set.test.ts @@ -105,14 +105,14 @@ describe("selectDockerDriverSandboxContainer", () => { expect(selected).toBe("openshell-demo"); }); - it("falls back to the generated Docker-driver sandbox container prefix", () => { - const selected = selectDockerDriverSandboxContainer( - "demo", - "docker", - "openshell-other\nopenshell-demo-abc123\n", - ); - - expect(selected).toBe("openshell-demo-abc123"); + it("does not select a prefix-matching container (label lookup is the supported path)", () => { + expect( + selectDockerDriverSandboxContainer( + "demo", + "docker", + "openshell-other\nopenshell-demo-abc123\n", + ), + ).toBeNull(); }); it("does not select a container for legacy gateway sandboxes", () => { diff --git a/test/e2e/test-brew-command.sh b/test/e2e/test-brew-command.sh new file mode 100755 index 00000000000..2d93faff2a5 --- /dev/null +++ b/test/e2e/test-brew-command.sh @@ -0,0 +1,300 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# ============================================================================= +# test-brew-command.sh +# NemoClaw `brew` subcommand E2E (#3757) +# +# Covers the first-class brew lifecycle: +# TC-BREW-01: `brew init` bootstraps Homebrew, /home/linuxbrew/.linuxbrew/bin/brew runnable +# TC-BREW-02: `brew install hello` lands a runnable bottled binary in the sandbox PATH +# TC-BREW-03: `brew uninstall hello` removes the bottle +# TC-BREW-04: `brew init` a second time is idempotent +# TC-BREW-05: `brew install` without prior init refuses with a clear error +# TC-BREW-06: `brew install` with shields up refuses with a clear error +# TC-BREW-07: `brew deinit` removes /home/linuxbrew and the linuxbrew user +# +# Prerequisites: +# - Docker running +# - NemoClaw installed (or install.sh available) +# - NVIDIA_API_KEY for sandbox onboard +# ============================================================================= + +set -euo pipefail + +export NEMOCLAW_E2E_DEFAULT_TIMEOUT=5400 +SCRIPT_DIR_TIMEOUT="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +# shellcheck source=test/e2e/e2e-timeout.sh +source "${SCRIPT_DIR_TIMEOUT}/e2e-timeout.sh" +# shellcheck source=test/e2e/lib/install-path-refresh.sh +source "${SCRIPT_DIR_TIMEOUT}/lib/install-path-refresh.sh" + +SANDBOX_NAME="e2e-brew-cmd" +LOG_FILE="test-brew-command-$(date +%Y%m%d-%H%M%S).log" + +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +PASS=0 +FAIL=0 +SKIP=0 +TOTAL=0 + +log() { echo -e "${CYAN}[$(date +%H:%M:%S)]${NC} $*" | tee -a "$LOG_FILE"; } +pass() { + ((PASS += 1)) + ((TOTAL += 1)) + echo -e "${GREEN} PASS${NC} $1" | tee -a "$LOG_FILE" +} +fail() { + ((FAIL += 1)) + ((TOTAL += 1)) + echo -e "${RED} FAIL${NC} $1 — $2" | tee -a "$LOG_FILE" +} +skip() { + ((SKIP += 1)) + ((TOTAL += 1)) + echo -e "${YELLOW} SKIP${NC} $1 — $2" | tee -a "$LOG_FILE" +} + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +install_nemoclaw() { + export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" + if [ -s "$NVM_DIR/nvm.sh" ]; then + # shellcheck source=/dev/null + . "$NVM_DIR/nvm.sh" + fi + nemoclaw_ensure_local_bin_on_path + if command -v nemoclaw >/dev/null 2>&1; then + log "nemoclaw already installed: $(nemoclaw --version 2>/dev/null || echo unknown)" + return + fi + log "=== Installing NemoClaw via install.sh ===" + NEMOCLAW_SANDBOX_NAME="$SANDBOX_NAME" \ + NVIDIA_API_KEY="${NVIDIA_API_KEY:-nvapi-DUMMY-FOR-INSTALL}" \ + NEMOCLAW_NON_INTERACTIVE=1 \ + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 \ + NEMOCLAW_POLICY_TIER="restricted" \ + bash "$REPO_ROOT/install.sh" --non-interactive --yes-i-accept-third-party-software \ + 2>&1 | tee -a "$LOG_FILE" + nemoclaw_refresh_install_env + if ! command -v nemoclaw >/dev/null 2>&1; then + log "ERROR: install.sh failed — nemoclaw not found" + exit 1 + fi +} + +preflight() { + log "=== Pre-flight checks ===" + if ! docker info >/dev/null 2>&1; then + log "ERROR: Docker is not running." + exit 1 + fi + log "Docker is running" + install_nemoclaw + log "nemoclaw: $(nemoclaw --version 2>/dev/null || echo unknown)" + log "Pre-flight complete" +} + +sandbox_exec() { + local cmd="$1" + local ssh_cfg + ssh_cfg="$(mktemp)" + if ! openshell sandbox ssh-config "$SANDBOX_NAME" >"$ssh_cfg" 2>/dev/null; then + log " [sandbox_exec] Failed to get SSH config" + rm -f "$ssh_cfg" + echo "" + return 1 + fi + local result ssh_exit=0 + result=$(run_with_timeout 120 ssh -F "$ssh_cfg" \ + -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + -o ConnectTimeout=10 -o LogLevel=ERROR \ + "openshell-${SANDBOX_NAME}" "$cmd" 2>&1) || ssh_exit=$? + rm -f "$ssh_cfg" + echo "$result" + return $ssh_exit +} + +setup_sandbox() { + local api_key="${NVIDIA_API_KEY:-}" + if [[ -z "$api_key" ]]; then + log "ERROR: NVIDIA_API_KEY not set" + exit 1 + fi + log "Preflight: destroying any existing '$SANDBOX_NAME' sandbox..." + nemoclaw "$SANDBOX_NAME" destroy --yes 2>/dev/null || true + + log "=== Onboarding sandbox '$SANDBOX_NAME' with restricted policy ===" + rm -f "$HOME/.nemoclaw/onboard.lock" 2>/dev/null || true + NEMOCLAW_SANDBOX_NAME="$SANDBOX_NAME" \ + NEMOCLAW_NON_INTERACTIVE=1 \ + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 \ + NEMOCLAW_POLICY_TIER="restricted" \ + NEMOCLAW_RECREATE_SANDBOX=1 \ + run_with_timeout 900 nemoclaw onboard --non-interactive --yes-i-accept-third-party-software \ + 2>&1 | tee -a "$LOG_FILE" || { + log "FATAL: Onboard failed" + exit 1 + } + log "Sandbox '$SANDBOX_NAME' onboarded with restricted policy" +} + +test_brew_init() { + log "" + log "=== TC-BREW-01: brew init bootstraps Homebrew ===" + local out exit_code=0 + out=$(run_with_timeout 1800 nemoclaw "$SANDBOX_NAME" brew init 2>&1) || exit_code=$? + echo "$out" | tee -a "$LOG_FILE" >/dev/null + if [[ "$exit_code" -ne 0 ]]; then + fail "TC-BREW-01" "brew init exited $exit_code" + return 1 + fi + local brew_bin + brew_bin=$(sandbox_exec '/home/linuxbrew/.linuxbrew/bin/brew --version 2>&1 || true') + if echo "$brew_bin" | grep -q '^Homebrew '; then + pass "TC-BREW-01" + else + fail "TC-BREW-01" "brew --version did not report Homebrew: $brew_bin" + fi +} + +test_brew_install_hello() { + log "" + log "=== TC-BREW-02: brew install hello lands a runnable bottle ===" + local exit_code=0 + run_with_timeout 1800 nemoclaw "$SANDBOX_NAME" brew install hello 2>&1 | tee -a "$LOG_FILE" || exit_code=$? + if [[ "$exit_code" -ne 0 ]]; then + fail "TC-BREW-02" "brew install hello exited $exit_code" + return 1 + fi + local hello_out + hello_out=$(sandbox_exec '/home/linuxbrew/.linuxbrew/bin/hello 2>&1 || true') + if echo "$hello_out" | grep -q 'Hello, world'; then + pass "TC-BREW-02" + else + fail "TC-BREW-02" "hello binary did not print expected greeting: $hello_out" + fi + + log "=== TC-BREW-02b: profile.d puts the linuxbrew prefix on the login-shell PATH ===" + local path_out + path_out=$(sandbox_exec 'bash -lc "command -v hello" 2>&1 || true') + if echo "$path_out" | grep -q '^/home/linuxbrew/\.linuxbrew/bin/hello$'; then + pass "TC-BREW-02b" + else + fail "TC-BREW-02b" "command -v hello did not resolve via login-shell PATH: $path_out" + fi +} + +test_brew_uninstall_hello() { + log "" + log "=== TC-BREW-03: brew uninstall hello removes the bottle ===" + local exit_code=0 + run_with_timeout 300 nemoclaw "$SANDBOX_NAME" brew uninstall hello 2>&1 | tee -a "$LOG_FILE" || exit_code=$? + if [[ "$exit_code" -ne 0 ]]; then + fail "TC-BREW-03" "brew uninstall hello exited $exit_code" + return 1 + fi + local present + present=$(sandbox_exec 'test -x /home/linuxbrew/.linuxbrew/bin/hello && echo present || echo absent') + if [[ "$present" == "absent" ]]; then + pass "TC-BREW-03" + else + fail "TC-BREW-03" "hello binary still present after uninstall" + fi +} + +test_init_twice_idempotent() { + log "" + log "=== TC-BREW-04: brew init twice is idempotent ===" + local exit_code=0 + local out + out=$(run_with_timeout 60 nemoclaw "$SANDBOX_NAME" brew init 2>&1) || exit_code=$? + echo "$out" | tee -a "$LOG_FILE" >/dev/null + if [[ "$exit_code" -eq 0 ]] && echo "$out" | grep -q 'already installed'; then + pass "TC-BREW-04" + else + fail "TC-BREW-04" "expected 'already installed' message, got exit=$exit_code out=$out" + fi +} + +test_shields_up_refuses() { + log "" + log "=== TC-BREW-06: brew install with shields up refuses ===" + nemoclaw "$SANDBOX_NAME" shields up 2>&1 | tee -a "$LOG_FILE" || { + skip "TC-BREW-06" "shields up failed; skipping" + return 0 + } + local exit_code=0 + local out + out=$(nemoclaw "$SANDBOX_NAME" brew install jq 2>&1) || exit_code=$? + echo "$out" | tee -a "$LOG_FILE" >/dev/null + nemoclaw "$SANDBOX_NAME" shields down 2>&1 | tee -a "$LOG_FILE" || true + if [[ "$exit_code" -ne 0 ]] && echo "$out" | grep -q 'shields up'; then + pass "TC-BREW-06" + else + fail "TC-BREW-06" "expected refusal with 'shields up' message, got exit=$exit_code out=$out" + fi +} + +test_brew_deinit() { + log "" + log "=== TC-BREW-07: brew deinit removes /home/linuxbrew ===" + local exit_code=0 + run_with_timeout 180 nemoclaw "$SANDBOX_NAME" brew deinit 2>&1 | tee -a "$LOG_FILE" || exit_code=$? + if [[ "$exit_code" -ne 0 ]]; then + fail "TC-BREW-07" "brew deinit exited $exit_code" + return 1 + fi + local present + present=$(sandbox_exec 'test -d /home/linuxbrew && echo present || echo absent') + if [[ "$present" == "absent" ]]; then + pass "TC-BREW-07" + else + fail "TC-BREW-07" "/home/linuxbrew still present after deinit" + fi +} + +test_install_without_init_refused() { + log "" + log "=== TC-BREW-05: brew install without prior init refuses ===" + local exit_code=0 + local out + out=$(nemoclaw "$SANDBOX_NAME" brew install jq 2>&1) || exit_code=$? + echo "$out" | tee -a "$LOG_FILE" >/dev/null + if [[ "$exit_code" -ne 0 ]] && echo "$out" | grep -q 'brew init'; then + pass "TC-BREW-05" + else + fail "TC-BREW-05" "expected refusal with 'brew init' hint, got exit=$exit_code out=$out" + fi +} + +main() { + trap 'nemoclaw "$SANDBOX_NAME" destroy --yes 2>/dev/null || true' EXIT + log "============================================================" + log "NemoClaw brew-command E2E (#3757)" + log "============================================================" + preflight + setup_sandbox + test_brew_init || true + test_brew_install_hello || true + test_brew_uninstall_hello || true + test_init_twice_idempotent || true + test_shields_up_refuses || true + test_brew_deinit || true + test_install_without_init_refused || true + log "" + log "============================================================" + log "Summary: PASS=$PASS FAIL=$FAIL SKIP=$SKIP TOTAL=$TOTAL" + log "============================================================" + if [[ "$FAIL" -gt 0 ]]; then + exit 1 + fi +} + +main "$@" diff --git a/test/onboard-policy-suggestions.test.ts b/test/onboard-policy-suggestions.test.ts index a884aab05aa..0df5216f0f6 100644 --- a/test/onboard-policy-suggestions.test.ts +++ b/test/onboard-policy-suggestions.test.ts @@ -33,7 +33,6 @@ describe("onboard policy preset suggestions", () => { "npm", "pypi", "huggingface", - "brew", "brave", "slack", "discord", @@ -108,7 +107,7 @@ describe("onboard policy preset suggestions", () => { enabledChannels: [], knownPresetNames: known, }); - expect(suggestions).toEqual(["npm", "pypi", "huggingface", "brew", "brave"]); + expect(suggestions).toEqual(["npm", "pypi", "huggingface", "brave"]); }); it("filters tier defaults to known presets for agent-specific onboarding", () => { @@ -116,7 +115,7 @@ describe("onboard policy preset suggestions", () => { enabledChannels: [], knownPresetNames: known.filter((name) => name !== "brave"), }); - expect(suggestions).toEqual(["npm", "pypi", "huggingface", "brew"]); + expect(suggestions).toEqual(["npm", "pypi", "huggingface"]); }); it("omits Brave when web search is unsupported", () => { @@ -137,7 +136,7 @@ describe("onboard policy preset suggestions", () => { knownPresetNames: known, webSearchSupported: false, }); - expect(suggestions).toEqual(["npm", "pypi", "huggingface", "brew"]); + expect(suggestions).toEqual(["npm", "pypi", "huggingface"]); }); it("forwards enabled messaging channels into tier suggestions", () => { diff --git a/test/onboard-preset-diff.test.ts b/test/onboard-preset-diff.test.ts index 9e3aec75873..454d1376dcf 100644 --- a/test/onboard-preset-diff.test.ts +++ b/test/onboard-preset-diff.test.ts @@ -3,7 +3,7 @@ // // Regression test for #2177 — when a user re-runs `nemoclaw onboard` on an // existing sandbox and narrows the preset selection (e.g. Balanced default -// of [npm, pypi, huggingface, brew, brave] down to just [npm]), the policy +// of [npm, pypi, huggingface, brave] down to just [npm]), the policy // setup step must honor the final selection: apply new presets AND remove // previously-applied ones that are no longer selected. @@ -45,7 +45,7 @@ function buildPreamble({ tierEnv = "balanced", policyMode = "custom", policyPresets = "npm", - alreadyApplied = ["npm", "pypi", "huggingface", "brew", "brave"], + alreadyApplied = ["npm", "pypi", "huggingface", "brave"], } = {}): string { const credPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "credentials", "store.js")); const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js")); @@ -143,9 +143,9 @@ console.log = () => {}; // User asked for only npm. assert.deepEqual(payload.chosen, ["npm"]); - // The 4 defaults from Balanced that the user did NOT re-select must be + // The 3 defaults from Balanced that the user did NOT re-select must be // removed. This is the regression guard for #2177. - const expectedRemoved = ["pypi", "huggingface", "brew", "brave"].sort(); + const expectedRemoved = ["pypi", "huggingface", "brave"].sort(); assert.deepEqual( payload.removedCalls.slice().sort(), expectedRemoved, @@ -171,7 +171,7 @@ console.log = () => {}; policyMode: "suggested", policyPresets: "", // Balanced defaults plus a manually-added preset. - alreadyApplied: ["npm", "pypi", "huggingface", "brew", "brave", "local-inference"], + alreadyApplied: ["npm", "pypi", "huggingface", "brave", "local-inference"], }) + String.raw` console.log = () => {}; @@ -205,7 +205,7 @@ console.log = () => {}; // Final state should still contain every previously-applied preset. const finalSorted = payload.finalApplied.slice().sort(); - assert.deepEqual(finalSorted, ["brave", "brew", "huggingface", "local-inference", "npm", "pypi"]); + assert.deepEqual(finalSorted, ["brave", "huggingface", "local-inference", "npm", "pypi"]); }); // Custom presets loaded via `policy-add --from-file` / `--from-dir` are @@ -217,7 +217,7 @@ console.log = () => {}; buildPreamble({ policyMode: "suggested", policyPresets: "", - alreadyApplied: ["npm", "pypi", "huggingface", "brew", "brave", "my-internal-api"], + alreadyApplied: ["npm", "pypi", "huggingface", "brave", "my-internal-api"], }) + String.raw` console.log = () => {}; @@ -251,7 +251,7 @@ console.log = () => {}; buildPreamble({ policyMode: "suggested", policyPresets: "", - alreadyApplied: ["npm", "pypi", "huggingface", "brew", "brave", "my-internal-api"], + alreadyApplied: ["npm", "pypi", "huggingface", "brave", "my-internal-api"], }) + String.raw` console.log = () => {}; @@ -282,7 +282,6 @@ console.log = () => {}; ); assert.deepEqual(payload.removedCalls, ["brave"]); assert.deepEqual(payload.finalApplied.slice().sort(), [ - "brew", "huggingface", "my-internal-api", "npm", diff --git a/test/policies.test.ts b/test/policies.test.ts index fe6590a9274..0cc994e5a79 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -129,9 +129,9 @@ selectFromList(items, options) describe("policies", () => { describe("listPresets", () => { - it("returns all 19 presets", () => { + it("returns all 18 presets", () => { const presets = policies.listPresets(); - expect(presets.length).toBe(19); + expect(presets.length).toBe(18); }); it("each preset has name and description", () => { @@ -148,7 +148,6 @@ describe("policies", () => { .sort(); const expected = [ "brave", - "brew", "discord", "github", "huggingface", @@ -440,7 +439,6 @@ describe("policies", () => { expect(policies.getMessagingPresetWarning("npm")).toBeNull(); expect(policies.getMessagingPresetWarning("pypi")).toBeNull(); expect(policies.getMessagingPresetWarning("github")).toBeNull(); - expect(policies.getMessagingPresetWarning("brew")).toBeNull(); }); it("returns null for unknown preset names", () => { diff --git a/test/policy-prune.test.ts b/test/policy-prune.test.ts new file mode 100644 index 00000000000..827c0956763 --- /dev/null +++ b/test/policy-prune.test.ts @@ -0,0 +1,151 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync, type SpawnSyncReturns } 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"; + +const REPO_ROOT = path.join(import.meta.dirname, ".."); + +function runScript(scriptBody: string): SpawnSyncReturns { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-prune-")); + const scriptPath = path.join(tmpDir, "script.js"); + fs.writeFileSync(scriptPath, scriptBody); + const result = spawnSync(process.execPath, [scriptPath], { + cwd: REPO_ROOT, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + NEMOCLAW_NON_INTERACTIVE: "1", + }, + timeout: 15000, + }); + fs.rmSync(tmpDir, { recursive: true, force: true }); + return result; +} + +function buildScript(args: { + policies: string[]; + customPolicies?: Array<{ name: string; content?: string }>; +}): string { + const policiesPath = JSON.stringify( + path.join(REPO_ROOT, "dist", "lib", "policy", "index.js"), + ); + const registryPath = JSON.stringify( + path.join(REPO_ROOT, "dist", "lib", "state", "registry.js"), + ); + + return String.raw` +const policies = require(${policiesPath}); +const registry = require(${registryPath}); + +const updates = []; +const initial = ${JSON.stringify({ + name: "alpha", + policies: args.policies, + customPolicies: args.customPolicies ?? [], + })}; + +registry.getSandbox = (name) => (name === "alpha" ? structuredClone(initial) : null); +registry.updateSandbox = (name, patch) => { + updates.push({ name, patch }); + Object.assign(initial, patch); + return true; +}; + +const warnings = []; +const origWarn = console.warn; +console.warn = (...args) => { warnings.push(args.join(" ")); }; + +const stale = policies.pruneStaleBuiltInPresets("alpha"); +console.warn = origWarn; + +process.stdout.write(JSON.stringify({ stale, updates, warnings })); +`; +} + +describe("policy.pruneStaleBuiltInPresets", () => { + it("returns empty and leaves the registry untouched when every preset is known", () => { + const result = runScript( + buildScript({ policies: ["npm", "brave"] }), + ); + expect(result.status).toBe(0); + const payload = JSON.parse(result.stdout); + expect(payload.stale).toEqual([]); + expect(payload.updates).toEqual([]); + expect(payload.warnings).toEqual([]); + }); + + it("drops stale built-in names, warns, and writes the cleaned list back", () => { + // `brew` and `legacy-nope` are not in the on-disk preset directory after + // #3757; `npm`, `huggingface`, and `brave` still are. + const result = runScript( + buildScript({ + policies: ["npm", "brew", "huggingface", "legacy-nope", "brave"], + }), + ); + expect(result.status).toBe(0); + const payload = JSON.parse(result.stdout); + expect(payload.stale).toEqual(["brew", "legacy-nope"]); + expect(payload.updates).toEqual([ + { name: "alpha", patch: { policies: ["npm", "huggingface", "brave"] } }, + ]); + expect(payload.warnings.join("\n")).toMatch(/dropping stale preset.*brew/); + expect(payload.warnings.join("\n")).toMatch(/legacy-nope/); + expect(payload.warnings.join("\n")).toMatch(/nemoclaw alpha rebuild/); + }); + + it("preserves custom preset names even when they are absent from listPresets()", () => { + const result = runScript( + buildScript({ + policies: ["npm", "my-internal-api", "brew"], + customPolicies: [{ name: "my-internal-api", content: "..." }], + }), + ); + expect(result.status).toBe(0); + const payload = JSON.parse(result.stdout); + expect(payload.stale).toEqual(["brew"]); + expect(payload.updates).toEqual([ + { name: "alpha", patch: { policies: ["npm", "my-internal-api"] } }, + ]); + }); +}); + +describe("policy.partitionKnownPresetNames", () => { + function partitionScript(presetNames: string[], customNames: string[]): string { + const policiesPath = JSON.stringify( + path.join(REPO_ROOT, "dist", "lib", "policy", "index.js"), + ); + return String.raw` +const policies = require(${policiesPath}); +process.stdout.write(JSON.stringify(policies.partitionKnownPresetNames(${JSON.stringify(presetNames)}, ${JSON.stringify(customNames)}))); +`; + } + + it("keeps every preset name when all are still defined", () => { + const result = runScript(partitionScript(["npm", "brave"], [])); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ known: ["npm", "brave"], stale: [] }); + }); + + it("flags names absent from both built-in and custom lists as stale", () => { + const result = runScript(partitionScript(["npm", "brew"], [])); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ known: ["npm"], stale: ["brew"] }); + }); + + it("treats custom preset names as known", () => { + const result = runScript( + partitionScript(["npm", "my-internal-api"], ["my-internal-api"]), + ); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + known: ["npm", "my-internal-api"], + stale: [], + }); + }); +}); diff --git a/test/policy-tiers-onboard.test.ts b/test/policy-tiers-onboard.test.ts index 416f735ade6..eb4987dbe4b 100644 --- a/test/policy-tiers-onboard.test.ts +++ b/test/policy-tiers-onboard.test.ts @@ -152,7 +152,7 @@ console.log = () => {}; assert.equal(result.status, 0, result.stderr); const payload = JSON.parse(result.stdout.trim()); assert.equal(payload.tier, "balanced"); - assert.ok(payload.presets.length >= 5, "balanced tier must have at least 5 presets"); + assert.ok(payload.presets.length >= 4, "balanced tier must have at least 4 presets"); for (const p of payload.presets) { assert.equal(p.access, "read-write", `preset ${p.name} in balanced should be read-write`); } @@ -753,7 +753,7 @@ ${body} assert.equal(result.status, 0, result.stderr); const resolved: Array<{ name: string }> = JSON.parse(result.stdout.trim()); const names = resolved.map((p) => p.name); - const tierNames = ["npm", "pypi", "huggingface", "brew", "brave"]; + const tierNames = ["npm", "pypi", "huggingface", "brave"]; const lastTierIdx = Math.max(...tierNames.map((n) => names.indexOf(n))); const slackIdx = names.indexOf("slack"); assert.ok(slackIdx > lastTierIdx, "non-tier preset (slack) should appear after tier presets"); diff --git a/test/policy-tiers.test.ts b/test/policy-tiers.test.ts index 6ba72cbea31..8beda45bdb1 100644 --- a/test/policy-tiers.test.ts +++ b/test/policy-tiers.test.ts @@ -119,17 +119,21 @@ describe("tiers", () => { }); describe("tier: balanced", () => { - it("includes npm, pypi, huggingface, brew, and brave", () => { + it("includes npm, pypi, huggingface, and brave", () => { const names = mustGetTier("balanced").presets.map((preset: TierPreset) => preset.name); expect(names).toContain("npm"); expect(names).toContain("pypi"); expect(names).toContain("huggingface"); - expect(names).toContain("brew"); expect(names).toContain("brave"); }); - it("has at least 5 presets", () => { - expect(mustGetTier("balanced").presets.length).toBeGreaterThanOrEqual(5); + it("has at least 4 presets", () => { + expect(mustGetTier("balanced").presets.length).toBeGreaterThanOrEqual(4); + }); + + it("does not include brew (replaced by first-class `nemoclaw brew init`, #3757)", () => { + const names = mustGetTier("balanced").presets.map((preset: TierPreset) => preset.name); + expect(names).not.toContain("brew"); }); it("all balanced presets are read-write", () => { @@ -192,7 +196,7 @@ describe("tiers", () => { describe("resolveTierPresets", () => { it("returns default presets for balanced with no overrides", () => { const resolved: TierPreset[] = tiers.resolveTierPresets("balanced"); - expect(resolved.length).toBeGreaterThanOrEqual(5); + expect(resolved.length).toBeGreaterThanOrEqual(4); for (const preset of resolved) { expect(preset.access).toBe("read-write"); } diff --git a/test/repro-2681-group-writable.test.ts b/test/repro-2681-group-writable.test.ts index 88f6bbcfd44..54ed11aa1ad 100644 --- a/test/repro-2681-group-writable.test.ts +++ b/test/repro-2681-group-writable.test.ts @@ -50,7 +50,11 @@ function withMockedDockerExecFileSync(calls: string[][], run: () => T): T { }; const originalDockerExecFileSync = dockerExecModule.dockerExecFileSync; const shieldsModulePath = require.resolve("../dist/lib/shields/index.js"); + const privilegedExecModulePath = require.resolve( + "../dist/lib/adapters/sandbox/privileged-exec.js", + ); delete require.cache[shieldsModulePath]; + delete require.cache[privilegedExecModulePath]; dockerExecModule.dockerExecFileSync = vi.fn((args: readonly string[]) => { const separator = args.indexOf("--"); @@ -72,6 +76,7 @@ function withMockedDockerExecFileSync(calls: string[][], run: () => T): T { } finally { dockerExecModule.dockerExecFileSync = originalDockerExecFileSync; delete require.cache[shieldsModulePath]; + delete require.cache[privilegedExecModulePath]; } } @@ -176,7 +181,7 @@ const Module = require("node:module"); const originalLoad = Module._load; const calls = []; Module._load = function patchedLoad(request, parent, isMain) { - if (request === "../adapters/docker/exec") { + if (request === "../docker/exec" || request === "../adapters/docker/exec") { return { dockerExecFileSync(args) { const separator = args.indexOf("--");