diff --git a/ci/platform-matrix.json b/ci/platform-matrix.json index 271b17923f..19b83a4c58 100644 --- a/ci/platform-matrix.json +++ b/ci/platform-matrix.json @@ -39,7 +39,7 @@ "status": "caveated", "prd_priority": "P0", "ci_tested": true, - "notes": "Start the container runtime (Colima or Docker Desktop) before running the installer. When Homebrew is available, OpenShell uses its official formula and the gateway appears in `brew services list` as `openshell`; without Homebrew, NemoClaw uses the standalone OpenShell install and detached gateway fallback. Homebrew Colima users must install both Colima and the Docker CLI (`brew install colima docker`) before `docker info` can work. Xcode Command Line Tools (`xcode-select --install`) are typically required for Node native modules during install. NemoClaw recommends them but does not enforce them during preflight." + "notes": "Start the container runtime (Colima or Docker Desktop) before running the installer. When Homebrew can load the pinned official OpenShell formula, the gateway appears in `brew services list` as `openshell`. When Homebrew 6.x returns the exact pinned-formula untrusted-tap refusal from `brew list` or `brew info`, NemoClaw checks the matching launchd unit with `launchctl print`. NemoClaw uses the detached standalone gateway fallback only when that command returns the exact missing-service result for `homebrew.mxcl.openshell`. Without Homebrew, NemoClaw uses the standalone OpenShell install and the same fallback. Homebrew Colima users must install both Colima and the Docker CLI (`brew install colima docker`) before `docker info` can work. Xcode Command Line Tools (`xcode-select --install`) are typically required for Node native modules during install. NemoClaw recommends but does not require them during preflight." }, { "name": "DGX OS (Spark)", diff --git a/docs/get-started/prerequisites.mdx b/docs/get-started/prerequisites.mdx index 9e7c1cd46c..203079df27 100644 --- a/docs/get-started/prerequisites.mdx +++ b/docs/get-started/prerequisites.mdx @@ -104,7 +104,7 @@ The table comes from [`ci/platform-matrix.json`](https://github.com/NVIDIA/NemoC | DGX OS (Spark) | Docker | Tested | Use the standard installer and `$$nemoclaw onboard`. The automatic two-DGX Spark managed-vLLM profile is Experimental, and physical two-node end-to-end validation is pending. For the profile requirements and controls, see [Set Up vLLM on Two DGX Sparks](../inference/local-inference/set-up-vllm-on-two-dgx-sparks). For the validated single-DGX Spark walkthrough with local inference, see the [NVIDIA Spark playbook](https://build.nvidia.com/spark/nemoclaw). | | DGX OS (Station) | Docker | Tested with limitations | Tested with limitations across qualified profiles on one physical DGX Station GB300; see [Additional Setup for DGX Station](additional-setup/dgx-station-preparation) for accepted profiles, the pending no-OTA DGX OS `7.6.x` end-to-end qualification, runtime gates, and current dual-Station and dedicated CI limitations. | | Linux | Docker | Tested | Primary tested path. Ubuntu 24.04 has host-level onboarding validation. A digest-pinned Ubuntu 26.04 userspace lane builds the CLI and runs preflight, installer, and platform contracts on eligible main pushes; Docker-host, AppArmor, Landlock, and live onboarding validation on 26.04 remain pending. Other distros (Ubuntu 22.04, Fedora, Rocky, Alma, NixOS, Arch) may work but are not validated. | -| macOS (Apple Silicon) | Colima, Docker Desktop | Tested with limitations | Start the container runtime (Colima or Docker Desktop) before running the installer. When Homebrew is available, OpenShell uses its official formula and the gateway appears in `brew services list` as `openshell`; without Homebrew, NemoClaw uses the standalone OpenShell install and detached gateway fallback. Homebrew Colima users must install both Colima and the Docker CLI (`brew install colima docker`) before `docker info` can work. Xcode Command Line Tools (`xcode-select --install`) are typically required for Node native modules during install. NemoClaw recommends them but does not enforce them during preflight. | +| macOS (Apple Silicon) | Colima, Docker Desktop | Tested with limitations | Start the container runtime (Colima or Docker Desktop) before running the installer. When Homebrew can load the pinned official OpenShell formula, the gateway appears in `brew services list` as `openshell`. When Homebrew 6.x returns the exact pinned-formula untrusted-tap refusal from `brew list` or `brew info`, NemoClaw checks the matching launchd unit with `launchctl print`. NemoClaw uses the detached standalone gateway fallback only when that command returns the exact missing-service result for `homebrew.mxcl.openshell`. Without Homebrew, NemoClaw uses the standalone OpenShell install and the same fallback. Homebrew Colima users must install both Colima and the Docker CLI (`brew install colima docker`) before `docker info` can work. Xcode Command Line Tools (`xcode-select --install`) are typically required for Node native modules during install. NemoClaw recommends but does not require them during preflight. | | Windows WSL2 | Docker Desktop (WSL backend) | Tested with limitations | Requires WSL2 with Docker Desktop backend. See [Additional Setup for Windows Machines](additional-setup/windows-preparation) before the Quickstart. | For the complete platform support matrix, including all deferred platforms and CI coverage, refer to [Platform Support](../reference/platform-support). diff --git a/docs/reference/architecture.mdx b/docs/reference/architecture.mdx index 508d21c978..4e98f0273e 100644 --- a/docs/reference/architecture.mdx +++ b/docs/reference/architecture.mdx @@ -107,6 +107,12 @@ On Apple Silicon macOS, Homebrew makes the official OpenShell formula authoritat The installer stages the formula and onboarding starts its `openshell` service. If the service fails inspection, startup, or its health check, NemoClaw prints the formula log command and attempts the standalone fallback. A host without Homebrew, or with no installed OpenShell formula, also uses the standalone macOS gateway. +Homebrew 6.x can return a pinned-formula untrusted-tap refusal from `brew list --formula openshell` or `brew info --json=v2 openshell`. +Only the complete refusal for `nvidia/openshell/openshell` from `nvidia/openshell` can select the standalone macOS gateway fallback. +Before the fallback, `launchctl print` must return the exact missing-service result for `homebrew.mxcl.openshell`. +Onboarding prints the recognized diagnostic one time without repeating raw Homebrew output when both conditions match. +Every other `brew info` identity failure or unrecognized `launchctl` result stops onboarding. +A loaded `homebrew.mxcl.openshell` service remains under launchd lifecycle authority, so onboarding does not stop, replace, or adopt its process. On both platforms, standalone startup requires exclusive ownership of the gateway port. A positively untrusted upstream package service is declined as described above. Trust failures in a marked NemoClaw service or Homebrew formula, and unsafe environment configuration, remain hard failures. diff --git a/docs/reference/platform-support.mdx b/docs/reference/platform-support.mdx index 502553460f..e9eabd03b4 100644 --- a/docs/reference/platform-support.mdx +++ b/docs/reference/platform-support.mdx @@ -81,7 +81,7 @@ For install requirements and the shorter setup-oriented platform view, refer to | DGX OS (Spark) | Docker | Tested | P1 | Yes | Use the standard installer and `$$nemoclaw onboard`. The automatic two-DGX Spark managed-vLLM profile is Experimental, and physical two-node end-to-end validation is pending. For the profile requirements and controls, see [Set Up vLLM on Two DGX Sparks](../inference/local-inference/set-up-vllm-on-two-dgx-sparks). For the validated single-DGX Spark walkthrough with local inference, see the [NVIDIA Spark playbook](https://build.nvidia.com/spark/nemoclaw). | | DGX OS (Station) | Docker | Tested with limitations | P1 | No | The PRD marks this platform as P1. Physical validation on one DGX Station GB300 covers generic Ubuntu 24.04 ARM64, stock DGX OS `7.5.0`, the April 2026 NVIDIA Colossus BaseOS profile, and the June 2026 NVIDIA AI Developer Tools profile. A physical no-OTA DGX OS `7.6.0` host provided the release and hardware profile used for its stable workstation-family classifier and passed read-only eligibility and runtime-command preflight. Full Station Express end-to-end qualification for the accepted no-OTA DGX OS `7.6.x` profile is pending. The profile remains subject to the same physical GB300, driver, ECC, Docker, CDI, and container GPU validation. Clean-host end-to-end validation passed on generic Ubuntu and Colossus BaseOS; stock DGX OS and AI Developer Tools completed Station Express validation. The DGX OS `7.5.0` run used released OpenShell `0.0.85`, local Nemotron Ultra serving, sandbox `cuInit(0)`, and a Hermes write/read file-tool task. A dual-Station configuration has not been validated, and dedicated CI coverage is not available. Direct-GPU policies expose only the exact read-only BDF directory for each discovered display-class PCI device with NVIDIA vendor ID (`0x10de`) and GB300 device ID (`0x31c2` or `0x31c3`) plus required existing topology and module paths; they do not expose `/sys`, the PCI parent subtree, or sysfs write access. During physical validation, reads of `/sys/fs/cgroup/cgroup.controllers` and `/sys/class/net/lo/address` remained denied. For canonical hardware qualification, image requirements, preparation, repair limits, reboot handoff, and the explicit temporary metadata override, see [Prepare DGX Station to Install NemoClaw](../get-started/additional-setup/dgx-station-preparation). | | Linux | Docker | Tested | P0 | Yes | Primary tested path. Ubuntu 24.04 has host-level onboarding validation. A digest-pinned Ubuntu 26.04 userspace lane builds the CLI and runs preflight, installer, and platform contracts on eligible main pushes; Docker-host, AppArmor, Landlock, and live onboarding validation on 26.04 remain pending. Other distros (Ubuntu 22.04, Fedora, Rocky, Alma, NixOS, Arch) may work but are not validated. | -| macOS (Apple Silicon) | Colima, Docker Desktop | Tested with limitations | P0 | Yes | Start the container runtime (Colima or Docker Desktop) before running the installer. When Homebrew is available, OpenShell uses its official formula and the gateway appears in `brew services list` as `openshell`; without Homebrew, NemoClaw uses the standalone OpenShell install and detached gateway fallback. Homebrew Colima users must install both Colima and the Docker CLI (`brew install colima docker`) before `docker info` can work. Xcode Command Line Tools (`xcode-select --install`) are typically required for Node native modules during install. NemoClaw recommends them but does not enforce them during preflight. | +| macOS (Apple Silicon) | Colima, Docker Desktop | Tested with limitations | P0 | Yes | Start the container runtime (Colima or Docker Desktop) before running the installer. When Homebrew can load the pinned official OpenShell formula, the gateway appears in `brew services list` as `openshell`. When Homebrew 6.x returns the exact pinned-formula untrusted-tap refusal from `brew list` or `brew info`, NemoClaw checks the matching launchd unit with `launchctl print`. NemoClaw uses the detached standalone gateway fallback only when that command returns the exact missing-service result for `homebrew.mxcl.openshell`. Without Homebrew, NemoClaw uses the standalone OpenShell install and the same fallback. Homebrew Colima users must install both Colima and the Docker CLI (`brew install colima docker`) before `docker info` can work. Xcode Command Line Tools (`xcode-select --install`) are typically required for Node native modules during install. NemoClaw recommends but does not require them during preflight. | | NVIDIA RTX (consumer and Pro workstation GPUs) | Docker | Deferred | P1 | No | The PRD marks this platform as P1. Covers RTX consumer cards and RTX Pro workstation cards on Linux hosts that meet the generic-Linux-GPU requirements (NVIDIA Container Toolkit + CDI present). The provider menu emits managed vLLM behind `NEMOCLAW_EXPERIMENTAL=1` or `NEMOCLAW_PROVIDER=install-vllm` for this host class today; the end-to-end onboard path on this hardware is not yet validated in CI. | | Windows WSL2 | Docker Desktop (WSL backend) | Tested with limitations | P1 | No | Requires WSL2 with Docker Desktop backend. | {/* platform-matrix-full:end */} diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx index 09ab640bd8..f69186ddec 100644 --- a/docs/reference/troubleshooting.mdx +++ b/docs/reference/troubleshooting.mdx @@ -1055,6 +1055,11 @@ Follow these steps to reconnect. ``` During onboarding, NemoClaw uses the standalone fallback when the `openshell` formula is missing. + If `brew list --formula openshell` or `brew info --json=v2 openshell` returns the exact pinned-formula untrusted-tap refusal, onboarding prints the recognized diagnostic once without repeating raw Homebrew output. + It selects the standalone fallback only when `launchctl print` also returns the exact missing-service result for `homebrew.mxcl.openshell`. + Every other `brew info` identity failure or unrecognized `launchctl` result stops onboarding. + A loaded `homebrew.mxcl.openshell` service remains under launchd lifecycle authority. + NemoClaw does not stop, replace, or adopt that process, and the error provides the `launchctl bootout` remediation command. If the installed service fails inspection, startup, or its health check, NemoClaw prints this log command: ```bash diff --git a/nemoclaw/src/security/credential-filter.test.ts b/nemoclaw/src/security/credential-filter.test.ts index dc82de8a7b..4285996592 100644 --- a/nemoclaw/src/security/credential-filter.test.ts +++ b/nemoclaw/src/security/credential-filter.test.ts @@ -95,7 +95,7 @@ describe("plugin credential-filter", () => { expect(isSafeCredentialPlaceholder("xoxb-OPENSHELL-RESOLVE-ENV-SLACK_TOKEN")).toBe(true); expect(isSafeCredentialPlaceholder(null)).toBe(false); expect(valueLooksLikeSecret("sk-abcdefghijklmnopqrstuvwxyz")).toBe(true); - expect(valueLooksLikeSecret("glpat-abcdefghijklmnopqrst")).toBe(true); + expect(valueLooksLikeSecret("glpat-abcdefghijklmnopqrst")).toBe(true); // gitleaks:allow -- credential-detector fixture expect(valueLooksLikeSecret("nvcf-abcdefghij")).toBe(true); expect(valueLooksLikeSecret("GITHUB_TOKEN=opaque-secret-value-123")).toBe(true); expect(valueLooksLikeSecret("apiKey=opaque-secret-value-123")).toBe(true); diff --git a/src/lib/onboard/docker-driver-gateway-service.test.ts b/src/lib/onboard/docker-driver-gateway-service.test.ts index bb39624646..4ccc4b6126 100644 --- a/src/lib/onboard/docker-driver-gateway-service.test.ts +++ b/src/lib/onboard/docker-driver-gateway-service.test.ts @@ -14,6 +14,7 @@ import { hasOpenShellGatewayUserService, NEMOCLAW_OPENSHELL_GATEWAY_USER_SERVICE_MARKER, OpenShellGatewayServiceTrustError, + type OpenShellGatewayUserServiceOptions, type SpawnSyncLikeResult, startOpenShellGatewayUserService, startPackageManagedDockerDriverGateway, @@ -39,6 +40,68 @@ function spawnResult(status = 0, stderr = "", stdout = ""): SpawnSyncLikeResult return { status, stderr, stdout }; } +const TEST_UID = 501; +const LAUNCHCTL_MISSING_OPENSHELL_SERVICE = [ + "Bad request.", + `Could not find service "homebrew.mxcl.openshell" in domain for user gui: ${TEST_UID}`, +].join("\n"); +const HOMEBREW_PINNED_TAP_LOAD_REFUSAL = + "Error: Refusing to load formula nvidia/openshell/openshell from untrusted tap nvidia/openshell."; +const HOMEBREW_OPENSHELL_NOT_INSTALLED = "Error: No such keg: /opt/homebrew/Cellar/openshell"; + +function expectHomebrewGateFailureBeforeMutation( + brewInfoResult: SpawnSyncLikeResult, + launchctlResult: SpawnSyncLikeResult, + expectedError: string, +): void { + const operations: Array<(options: OpenShellGatewayUserServiceOptions) => unknown> = [ + hasOpenShellGatewayUserService, + startOpenShellGatewayUserService, + stopOpenShellGatewayUserService, + ]; + + for (const operation of operations) { + const preparePortForServiceStart = vi.fn(); + const prepareServiceEnv = vi.fn(); + const validatePortOwnerForServiceStart = vi.fn(); + const spawnSyncImpl = vi.fn((command: string, args: string[]) => + command === "launchctl" + ? launchctlResult + : args[0] === "info" + ? brewInfoResult + : spawnResult(), + ); + + let thrown: unknown; + try { + operation({ + commandExists: () => true, + getuid: () => TEST_UID, + platform: "darwin", + preparePortForServiceStart, + prepareServiceEnv, + spawnSyncImpl, + validatePortOwnerForServiceStart, + }); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(OpenShellGatewayServiceTrustError); + expect(thrown).toHaveProperty("message", expect.stringContaining(expectedError)); + expect(preparePortForServiceStart).not.toHaveBeenCalled(); + expect(prepareServiceEnv).not.toHaveBeenCalled(); + expect(validatePortOwnerForServiceStart).not.toHaveBeenCalled(); + expect( + spawnSyncImpl.mock.calls.some( + ([command, args]) => + (command === "brew" && args[0] === "services") || + (command === "launchctl" && args[0] !== "print") || + command === "kill", + ), + ).toBe(false); + } +} + function trustedShowOutput( fragmentPath = "/lib/systemd/user/openshell-gateway.service", execPath = "/usr/bin/openshell-gateway", @@ -153,12 +216,285 @@ describe("docker-driver-gateway-service", () => { ).toThrow("must come from nvidia/openshell"); }); + it("continues without the Homebrew service only for the exact pinned-tap refusal (#7707)", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + for (const refusalAt of ["list", "info"] as const) { + const options = { + commandExists: () => true, + getuid: () => TEST_UID, + platform: "darwin" as NodeJS.Platform, + spawnSyncImpl: vi.fn((command: string, args: string[]) => + command === "launchctl" + ? spawnResult(113, LAUNCHCTL_MISSING_OPENSHELL_SERVICE) + : args[0] === refusalAt + ? spawnResult(1, HOMEBREW_PINNED_TAP_LOAD_REFUSAL) + : spawnResult(), + ), + }; + + expect(hasOpenShellGatewayUserService(options)).toBe(false); + expect(options.spawnSyncImpl).toHaveBeenCalledWith( + "launchctl", + ["print", `gui/${TEST_UID}/homebrew.mxcl.openshell`], + expect.any(Object), + ); + } + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("using the standalone gateway fallback"), + ); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("Refusing to load formula")); + }); + + it("keeps an altered brew list refusal fatal before probing launchd (#7707)", () => { + const alteredRefusal = `${HOMEBREW_PINNED_TAP_LOAD_REFUSAL}\nPermission denied.`; + const spawn = vi.fn((_command: string, args: string[]) => + args[0] === "list" ? spawnResult(1, alteredRefusal) : spawnResult(), + ); + + expect(() => + hasOpenShellGatewayUserService({ + commandExists: () => true, + getuid: () => TEST_UID, + platform: "darwin", + spawnSyncImpl: spawn, + }), + ).toThrow( + "OpenShell Homebrew formula identity check failed; " + + "the unrecognized Homebrew diagnostic was omitted.", + ); + expect(spawn.mock.calls.map(([command, args]) => [command, ...args])).toEqual([ + ["brew", "list", "--formula", "openshell"], + ]); + }); + + it("aborts instead of falling back while the Homebrew launchd service is loaded (#7707)", () => { + const spawn = vi.fn((command: string, args: string[]) => + command === "launchctl" + ? spawnResult(0, "", '{"PID" = 4242;}') + : args[0] === "info" + ? spawnResult(1, HOMEBREW_PINNED_TAP_LOAD_REFUSAL) + : spawnResult(), + ); + + let thrown: unknown; + try { + hasOpenShellGatewayUserService({ + commandExists: () => true, + getuid: () => TEST_UID, + platform: "darwin", + spawnSyncImpl: spawn, + }); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(OpenShellGatewayServiceTrustError); + expect(thrown).toHaveProperty( + "message", + expect.stringContaining("its launchd service homebrew.mxcl.openshell is loaded"), + ); + expect(spawn.mock.calls.map(([command, args]) => [command, ...args])).toEqual([ + ["brew", "list", "--formula", "openshell"], + ["brew", "info", "--json=v2", "openshell"], + ["launchctl", "print", `gui/${TEST_UID}/homebrew.mxcl.openshell`], + ]); + }); + + it.each([ + ["returns a generic nonzero result", spawnResult(1, "Operation not permitted")], + [ + "returns the exact missing-service text with a different status", + spawnResult(1, LAUNCHCTL_MISSING_OPENSHELL_SERVICE), + ], + [ + "adds text to the missing-service result", + spawnResult(113, `${LAUNCHCTL_MISSING_OPENSHELL_SERVICE}\nTry again later.`), + ], + [ + "prefixes the missing-service result with whitespace", + spawnResult(113, ` ${LAUNCHCTL_MISSING_OPENSHELL_SERVICE}`), + ], + [ + "suffixes the missing-service result with whitespace", + spawnResult(113, `${LAUNCHCTL_MISSING_OPENSHELL_SERVICE}\n`), + ], + [ + "repeats whitespace in the missing-service result", + spawnResult( + 113, + LAUNCHCTL_MISSING_OPENSHELL_SERVICE.replace("Bad request.", "Bad request."), + ), + ], + [ + "adds a tab to the missing-service result", + spawnResult( + 113, + LAUNCHCTL_MISSING_OPENSHELL_SERVICE.replace("Could not find", "Could not\tfind"), + ), + ], + [ + "inserts a line break in the missing-service result", + spawnResult( + 113, + LAUNCHCTL_MISSING_OPENSHELL_SERVICE.replace("Could not find", "Could not\nfind"), + ), + ], + [ + "uses CRLF in the missing-service result", + spawnResult(113, LAUNCHCTL_MISSING_OPENSHELL_SERVICE.replaceAll("\n", "\r\n")), + ], + ["writes whitespace to stdout", spawnResult(113, LAUNCHCTL_MISSING_OPENSHELL_SERVICE, " \n")], + [ + "writes the missing-service result to stdout", + spawnResult(113, "", LAUNCHCTL_MISSING_OPENSHELL_SERVICE), + ], + [ + "reports a different missing unit", + spawnResult( + 113, + 'Bad request.\nCould not find service "homebrew.mxcl.other" in domain for user gui: 501', + ), + ], + [ + "reports the expected unit for a different user", + spawnResult( + 113, + 'Bad request.\nCould not find service "homebrew.mxcl.openshell" in domain for user gui: 502', + ), + ], + ["cannot run", { error: new Error("spawn launchctl ENOENT"), status: null }], + ["reports no exit status", { status: null }], + ])("aborts instead of falling back when the launchd probe %s (#7707)", (_case, launchdResult) => { + expectHomebrewGateFailureBeforeMutation( + spawnResult(1, HOMEBREW_PINNED_TAP_LOAD_REFUSAL), + launchdResult, + "could not determine whether its launchd service homebrew.mxcl.openshell is loaded", + ); + }); + + it.each([ + ["a generic brew info failure", spawnResult(1, "Error: Permission denied")], + [ + "a refused foreign-tap formula", + spawnResult( + 1, + "Error: Refusing to load formula other/tap/openshell from untrusted tap other/tap.", + ), + ], + [ + "a refusal naming a tap that only starts with the pinned name", + spawnResult( + 1, + "Error: Refusing to load formula nvidia/openshell/openshell from untrusted tap nvidia/openshell-fork.", + ), + ], + [ + "a refusal naming a dot-separated neighbor of the pinned tap", + spawnResult( + 1, + "Error: Refusing to load formula nvidia/openshell/openshell from untrusted tap nvidia/openshell.fork.", + ), + ], + [ + "a refusal naming another formula from the pinned tap", + spawnResult( + 1, + "Error: Refusing to load formula nvidia/openshell/openshell-extra from untrusted tap nvidia/openshell.", + ), + ], + [ + "the pinned refusal followed by another diagnostic", + spawnResult(1, `${HOMEBREW_PINNED_TAP_LOAD_REFUSAL}\nPermission denied.`), + ], + [ + "another diagnostic followed by the pinned refusal", + spawnResult(1, `Permission denied.\n${HOMEBREW_PINNED_TAP_LOAD_REFUSAL}`), + ], + [ + "leading whitespace before the pinned refusal", + spawnResult(1, ` ${HOMEBREW_PINNED_TAP_LOAD_REFUSAL}`), + ], + [ + "trailing whitespace after the pinned refusal", + spawnResult(1, `${HOMEBREW_PINNED_TAP_LOAD_REFUSAL}\n`), + ], + [ + "repeated whitespace in the pinned refusal", + spawnResult(1, HOMEBREW_PINNED_TAP_LOAD_REFUSAL.replace("load formula", "load formula")), + ], + [ + "a tab in the pinned refusal", + spawnResult(1, HOMEBREW_PINNED_TAP_LOAD_REFUSAL.replace("load formula", "load\tformula")), + ], + [ + "an inserted line break in the pinned refusal", + spawnResult(1, HOMEBREW_PINNED_TAP_LOAD_REFUSAL.replace("load formula", "load\nformula")), + ], + [ + "CRLF in the pinned refusal", + spawnResult(1, HOMEBREW_PINNED_TAP_LOAD_REFUSAL.replace("load formula", "load\r\nformula")), + ], + [ + "stdout alongside the exact pinned refusal", + spawnResult(1, HOMEBREW_PINNED_TAP_LOAD_REFUSAL, "unexpected stdout"), + ], + [ + "a spawn error alongside the exact pinned refusal", + { + error: new Error("spawn brew failed"), + status: 1, + stderr: HOMEBREW_PINNED_TAP_LOAD_REFUSAL, + stdout: "", + }, + ], + ])("keeps %s fatal during the formula identity check (#7707)", (_case, brewInfoResult) => { + expectHomebrewGateFailureBeforeMutation( + brewInfoResult, + spawnResult(113, LAUNCHCTL_MISSING_OPENSHELL_SERVICE), + "OpenShell Homebrew formula identity check failed; " + + "the unrecognized Homebrew diagnostic was omitted.", + ); + }); + + it("omits unrecognized Homebrew diagnostics from fatal output (#7707)", () => { + const secret = "api_key=opaque-homebrew-diagnostic"; + expect(() => + hasOpenShellGatewayUserService({ + commandExists: () => true, + platform: "darwin", + spawnSyncImpl: vi.fn((_command: string, args: string[]) => + args[0] === "info" ? spawnResult(1, `Error: Permission denied ${secret}`) : spawnResult(), + ), + }), + ).toThrow(expect.not.stringContaining(secret)); + }); + + it("skips the managed start when no trusted Homebrew service is selected (#7707)", async () => { + const startService = vi.fn(() => { + throw new Error("managed start must not run"); + }); + const started = await startPackageManagedDockerDriverGateway({ + clearDockerDriverGatewayRuntimeFiles: () => {}, + exitOnFailure: false, + gatewayName: "nemoclaw", + hasOpenShellGatewayUserService: () => false, + registerDockerDriverGatewayEndpoint: () => true, + runCaptureOpenshell: () => "", + skipSandboxBridgeReachability: true, + startOpenShellGatewayUserService: startService, + verifySandboxBridgeGatewayReachableOrExit: async () => {}, + }); + + expect(started).toBe(false); + expect(startService).not.toHaveBeenCalled(); + }); + it("reports no managed service when the Homebrew formula is missing (#8104)", () => { expect( hasOpenShellGatewayUserService({ commandExists: () => true, platform: "darwin", - spawnSyncImpl: () => spawnResult(1, "formula not installed"), + spawnSyncImpl: () => spawnResult(1, HOMEBREW_OPENSHELL_NOT_INSTALLED), }), ).toBe(false); }); @@ -738,7 +1074,7 @@ describe("docker-driver-gateway-service", () => { hasOpenShellGatewayUserService({ commandExists: () => true, platform: "darwin", - spawnSyncImpl: () => spawnResult(1, "formula not installed"), + spawnSyncImpl: () => spawnResult(1, HOMEBREW_OPENSHELL_NOT_INSTALLED), }), managedServiceLogCommand: getOpenShellGatewayManagedServiceLogCommand({ platform: "darwin", diff --git a/src/lib/onboard/docker-driver-gateway-service.ts b/src/lib/onboard/docker-driver-gateway-service.ts index 9db5fd4d5f..16ee7a8288 100644 --- a/src/lib/onboard/docker-driver-gateway-service.ts +++ b/src/lib/onboard/docker-driver-gateway-service.ts @@ -33,6 +33,7 @@ export interface OpenShellGatewayUserServiceOptions { commandExists?: (command: string) => boolean; env?: NodeJS.ProcessEnv; existsSync?: (filePath: string) => boolean; + getuid?: () => number; /** Test seam: read the version output of the package-managed gateway binary. */ getUpstreamGatewayVersion?: (binaryPath: string) => string | null; /** Test seam: the blueprint version window the gateway binary must satisfy. */ @@ -344,21 +345,34 @@ function runCommand( command: string, args: string[], opts: Required>, -): { ok: boolean; reason?: string; stdout?: string } { +): { + ok: boolean; + rawStderr: string; + rawStdout: string; + reason?: string; + spawnError?: Error; + status: number | null; + stdout?: string; +} { const result = opts.spawnSyncImpl(command, args, { encoding: "utf-8", env: opts.env, stdio: ["ignore", "pipe", "pipe"], } satisfies SpawnSyncOptions); - if (result.error) return { ok: false, reason: result.error.message }; + const rawStderr = text(result.stderr); + const rawStdout = text(result.stdout); + const rawResult = { rawStderr, rawStdout, status: result.status }; + if (result.error) { + return { ...rawResult, ok: false, reason: result.error.message, spawnError: result.error }; + } if (result.status !== 0) { return { + ...rawResult, ok: false, - reason: - text(result.stderr).trim() || text(result.stdout).trim() || `exit ${String(result.status)}`, + reason: rawStderr.trim() || rawStdout.trim() || `exit ${String(result.status)}`, }; } - return { ok: true, stdout: text(result.stdout) }; + return { ...rawResult, ok: true, stdout: rawStdout }; } function runSystemctlUser( @@ -427,10 +441,106 @@ function hasUpstreamOpenShellGatewayUserService( return getOpenShellGatewayUserServicePaths().some(existsSync); } +const warnedHomebrewIdentityCheckReasons = new Set(); + +const HOMEBREW_PINNED_TAP_LOAD_REFUSAL = + `Error: Refusing to load formula ${OPENSHELL_GATEWAY_HOMEBREW_TAP}/${OPENSHELL_GATEWAY_HOMEBREW_SERVICE} ` + + `from untrusted tap ${OPENSHELL_GATEWAY_HOMEBREW_TAP}.`; +const HOMEBREW_OPENSHELL_NOT_INSTALLED = `Error: No such keg: /opt/homebrew/Cellar/${OPENSHELL_GATEWAY_HOMEBREW_SERVICE}`; + +function isHomebrewFormulaNotInstalled(result: ReturnType): boolean { + return ( + !result.ok && + !result.spawnError && + typeof result.status === "number" && + result.status !== 0 && + result.rawStdout === "" && + result.rawStderr === HOMEBREW_OPENSHELL_NOT_INSTALLED + ); +} + +// Only the complete Homebrew 6.x refusal for the pinned formula can relax the +// identity check. Changed or additional diagnostic text fails closed (#7707). +// Remove this compatibility path when the minimum supported Homebrew version +// can inspect the pinned formula without trusting or loading its tap. +function isPinnedTapLoadRefusal(result: ReturnType): boolean { + return ( + !result.ok && + !result.spawnError && + typeof result.status === "number" && + result.status !== 0 && + result.rawStdout === "" && + result.rawStderr === HOMEBREW_PINNED_TAP_LOAD_REFUSAL + ); +} + +// `launchctl` must prove that the exact managed unit is absent before +// standalone ownership is selected. Every other outcome leaves authority +// unknown and fails closed. +function readHomebrewGatewayLaunchdUnitState( + opts: Required> & + Pick, +): "loaded" | "not-loaded" | "unknown" { + const getuid = opts.getuid ?? process.getuid; + const uid = getuid?.(); + if (typeof uid !== "number" || !Number.isSafeInteger(uid) || uid < 0) return "unknown"; + const unit = `homebrew.mxcl.${OPENSHELL_GATEWAY_HOMEBREW_SERVICE}`; + const result = opts.spawnSyncImpl("launchctl", ["print", `gui/${String(uid)}/${unit}`], { + encoding: "utf-8", + env: opts.env, + stdio: ["ignore", "pipe", "pipe"], + } satisfies SpawnSyncOptions); + if (result.error || typeof result.status !== "number") return "unknown"; + if (result.status === 0) return "loaded"; + const missingUnitError = [ + "Bad request.", + `Could not find service "${unit}" in domain for user gui: ${String(uid)}`, + ].join("\n"); + return result.status === 113 && + text(result.stdout) === "" && + text(result.stderr) === missingUnitError + ? "not-loaded" + : "unknown"; +} + +function warnHomebrewIdentityCheckUnavailable(): void { + const warningKey = HOMEBREW_PINNED_TAP_LOAD_REFUSAL; + if (warnedHomebrewIdentityCheckReasons.has(warningKey)) return; + warnedHomebrewIdentityCheckReasons.add(warningKey); + console.warn( + " Homebrew could not confirm the OpenShell formula identity; " + + `using the standalone gateway fallback.\n ${HOMEBREW_PINNED_TAP_LOAD_REFUSAL}`, + ); +} + +function allowStandaloneForPinnedTapLoadRefusal( + result: ReturnType, + opts: Required> & + Pick, +): boolean { + if (!isPinnedTapLoadRefusal(result)) return false; + const launchdState = readHomebrewGatewayLaunchdUnitState(opts); + if (launchdState !== "not-loaded") { + const unit = `homebrew.mxcl.${OPENSHELL_GATEWAY_HOMEBREW_SERVICE}`; + const situation = + launchdState === "loaded" + ? `its launchd service ${unit} is loaded` + : `NemoClaw could not determine whether its launchd service ${unit} is loaded`; + throw new OpenShellGatewayServiceTrustError( + `Homebrew refused to load the pinned OpenShell formula and ${situation}. ` + + `NemoClaw will not manage or replace a service that launchd owns. ` + + `Stop it with launchctl bootout gui/$(id -u)/${unit}, and rerun onboarding. ` + + `Homebrew reported: ${HOMEBREW_PINNED_TAP_LOAD_REFUSAL}`, + ); + } + warnHomebrewIdentityCheckUnavailable(); + return true; +} + function hasOfficialHomebrewFormula( opts: Pick< OpenShellGatewayUserServiceOptions, - "commandExists" | "env" | "platform" | "spawnSyncImpl" + "commandExists" | "env" | "getuid" | "platform" | "spawnSyncImpl" >, ): boolean { if ((opts.platform ?? process.platform) !== "darwin") return false; @@ -438,17 +548,43 @@ function hasOfficialHomebrewFormula( const commandExists = opts.commandExists ?? ((command) => defaultCommandExists(command, env)); if (!commandExists("brew")) return false; const spawnSyncImpl = opts.spawnSyncImpl ?? spawnSync; - if ( - !runBrew(["list", "--formula", OPENSHELL_GATEWAY_HOMEBREW_SERVICE], { env, spawnSyncImpl }).ok - ) - return false; + const listed = runBrew(["list", "--formula", OPENSHELL_GATEWAY_HOMEBREW_SERVICE], { + env, + spawnSyncImpl, + }); + if (!listed.ok) { + if (isHomebrewFormulaNotInstalled(listed)) return false; + if ( + allowStandaloneForPinnedTapLoadRefusal(listed, { + env, + getuid: opts.getuid, + spawnSyncImpl, + }) + ) { + return false; + } + throw new OpenShellGatewayServiceTrustError( + "OpenShell Homebrew formula identity check failed; " + + "the unrecognized Homebrew diagnostic was omitted.", + ); + } const info = runBrew(["info", "--json=v2", OPENSHELL_GATEWAY_HOMEBREW_SERVICE], { env, spawnSyncImpl, }); if (!info.ok) { + if ( + allowStandaloneForPinnedTapLoadRefusal(info, { + env, + getuid: opts.getuid, + spawnSyncImpl, + }) + ) { + return false; + } throw new OpenShellGatewayServiceTrustError( - `OpenShell Homebrew formula identity check failed: ${info.reason}`, + "OpenShell Homebrew formula identity check failed; " + + "the unrecognized Homebrew diagnostic was omitted.", ); } try { diff --git a/src/lib/onboard/gateway-host-runtime-homebrew-untrusted-tap.test.ts b/src/lib/onboard/gateway-host-runtime-homebrew-untrusted-tap.test.ts new file mode 100644 index 0000000000..167a53a152 --- /dev/null +++ b/src/lib/onboard/gateway-host-runtime-homebrew-untrusted-tap.test.ts @@ -0,0 +1,160 @@ +// 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"; + +import { createGatewayHostRuntime, type GatewayHostRuntimeDeps } from "./gateway-host-runtime"; +import type { PortProbeResult } from "./preflight"; + +const commandState = vi.hoisted(() => ({ + brewInfo: { + status: 1 as number | null, + stderr: + "Error: Refusing to load formula nvidia/openshell/openshell from untrusted tap nvidia/openshell.", + stdout: "", + }, + calls: [] as string[][], + launchctl: { + status: 113 as number | null, + stderr: + 'Bad request.\nCould not find service "homebrew.mxcl.openshell" in domain for user gui: 501', + stdout: "", + }, +})); + +// Homebrew 6.x can refuse the pinned formula during brew info. +// This mock keeps the production spawnSync boundary in the owner resolution. +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + spawnSync: vi.fn((command: string, args: readonly string[]) => { + commandState.calls.push([command, ...args]); + return command === "brew" && args[0] === "info" + ? { ...commandState.brewInfo } + : command === "launchctl" + ? { ...commandState.launchctl } + : { status: 0, stderr: "", stdout: "" }; + }), + }; +}); + +const ORIGINAL_ENV = { ...process.env }; +const HOMEBREW_PINNED_TAP_LOAD_REFUSAL = + "Error: Refusing to load formula nvidia/openshell/openshell from untrusted tap nvidia/openshell."; +const LAUNCHCTL_MISSING_OPENSHELL_SERVICE = + 'Bad request.\nCould not find service "homebrew.mxcl.openshell" in domain for user gui: 501'; +const HOMEBREW_IDENTITY_PROBES = [ + ["sh", "-c", 'command -v "$1" >/dev/null 2>&1', "sh", "brew"], + ["brew", "list", "--formula", "openshell"], + ["brew", "info", "--json=v2", "openshell"], +]; +const HOMEBREW_AND_LAUNCHCTL_PROBES = [ + ...HOMEBREW_IDENTITY_PROBES, + ["launchctl", "print", "gui/501/homebrew.mxcl.openshell"], +]; + +beforeEach(() => { + commandState.calls.length = 0; + Object.assign(commandState.brewInfo, { + status: 1, + stderr: HOMEBREW_PINNED_TAP_LOAD_REFUSAL, + stdout: "", + }); + Object.assign(commandState.launchctl, { + status: 113, + stderr: LAUNCHCTL_MISSING_OPENSHELL_SERVICE, + stdout: "", + }); + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + vi.spyOn(process, "getuid").mockReturnValue(501); + vi.spyOn(console, "warn").mockImplementation(() => {}); +}); + +afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + vi.restoreAllMocks(); +}); + +function createDeps(): GatewayHostRuntimeDeps { + return { + applyOverlayfsAutoFix: () => null, + checkGatewayPortAvailable: async () => ({ ok: true }) as PortProbeResult, + gatewayName: () => "nemoclaw", + gatewayPort: () => 8080, + getGatewayPortListenerRawScan: () => ({ pids: [], complete: true }), + getInstalledOpenshellVersion: () => "0.0.85", + runCaptureOpenshell: () => "healthy", + runOpenshell: () => ({ status: 0 }), + resolveOpenShellGatewayBinary: () => null, + waitForGatewayHttpReady: async () => true, + }; +} + +describe("gateway host runtime on Homebrew 6.x untrusted tap", () => { + it("selects standalone ownership after the exact refusal and missing-unit result (#7707)", () => { + const runtime = createGatewayHostRuntime(createDeps()); + + expect(runtime.getGatewayOwner()).toMatchObject({ + gatewayName: "nemoclaw", + gatewayPort: 8080, + mode: "nemoclaw-managed", + source: "standalone", + }); + expect(runtime.getGatewayOwner()).toMatchObject({ source: "standalone" }); + expect(console.warn).toHaveBeenCalledTimes(1); + }); + + it.each([ + ["leading whitespace", ` ${HOMEBREW_PINNED_TAP_LOAD_REFUSAL}`, ""], + ["trailing whitespace", `${HOMEBREW_PINNED_TAP_LOAD_REFUSAL}\n`, ""], + [ + "repeated whitespace", + HOMEBREW_PINNED_TAP_LOAD_REFUSAL.replace("load formula", "load formula"), + "", + ], + ["a tab", HOMEBREW_PINNED_TAP_LOAD_REFUSAL.replace("load formula", "load\tformula"), ""], + [ + "an inserted line break", + HOMEBREW_PINNED_TAP_LOAD_REFUSAL.replace("load formula", "load\nformula"), + "", + ], + ["CRLF", HOMEBREW_PINNED_TAP_LOAD_REFUSAL.replace("load formula", "load\r\nformula"), ""], + ["unexpected stdout", HOMEBREW_PINNED_TAP_LOAD_REFUSAL, "unexpected stdout"], + ])("rejects Homebrew diagnostic variation %s before owner selection (#7707)", (_case, stderr, stdout) => { + Object.assign(commandState.brewInfo, { stderr, stdout }); + const runtime = createGatewayHostRuntime(createDeps()); + + expect(() => runtime.getGatewayOwner()).toThrow( + "OpenShell Homebrew formula identity check failed; " + + "the unrecognized Homebrew diagnostic was omitted.", + ); + expect(commandState.calls).toEqual(HOMEBREW_IDENTITY_PROBES); + }); + + it.each([ + ["leading whitespace", ` ${LAUNCHCTL_MISSING_OPENSHELL_SERVICE}`, ""], + ["trailing whitespace", `${LAUNCHCTL_MISSING_OPENSHELL_SERVICE}\n`, ""], + [ + "repeated whitespace", + LAUNCHCTL_MISSING_OPENSHELL_SERVICE.replace("Bad request.", "Bad request."), + "", + ], + ["a tab", LAUNCHCTL_MISSING_OPENSHELL_SERVICE.replace("Could not find", "Could not\tfind"), ""], + [ + "an inserted line break", + LAUNCHCTL_MISSING_OPENSHELL_SERVICE.replace("Could not find", "Could not\nfind"), + "", + ], + ["CRLF", LAUNCHCTL_MISSING_OPENSHELL_SERVICE.replaceAll("\n", "\r\n"), ""], + ["whitespace-only stdout", LAUNCHCTL_MISSING_OPENSHELL_SERVICE, " \n"], + ])("rejects launchctl diagnostic variation %s before owner selection (#7707)", (_case, stderr, stdout) => { + Object.assign(commandState.launchctl, { stderr, stdout }); + const runtime = createGatewayHostRuntime(createDeps()); + + expect(() => runtime.getGatewayOwner()).toThrow( + "could not determine whether its launchd service homebrew.mxcl.openshell is loaded", + ); + expect(commandState.calls).toEqual(HOMEBREW_AND_LAUNCHCTL_PROBES); + }); +});