diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index d8d95f88c5..8304f0f06f 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -7,7 +7,7 @@ "src/lib/onboard/preflight.test.ts": 1905, "test/channels-add-preset.test.ts": 1872, "test/generate-openclaw-config.test.ts": 2091, - "test/install-preflight.test.ts": 4396, + "test/install-preflight.test.ts": 4207, "test/nemoclaw-start.test.ts": 5289, "test/onboard-messaging.test.ts": 2097, "test/onboard-selection.test.ts": 6922, diff --git a/scripts/install.sh b/scripts/install.sh index 6362287588..bf35b94fd8 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2193,6 +2193,39 @@ run_onboard() { # instructions to relogin/newgrp — Linux only loads group membership at # login, so the rest of this script (onboard, etc.) would fail otherwise. # Skipped on macOS (Docker Desktop) and inside WSL (host-managed Docker). +report_unexpected_docker_access() { + # If Docker is reachable, installation can continue. Still surface the + # unusual QA/security posture where a non-root user outside the docker group + # can control the daemon, because that makes "non-docker user denied" checks + # non-reproducible on this host. + if [ "$(id -u 2>/dev/null || printf 1)" -eq 0 ]; then + return 0 + fi + + local current_user + current_user="$(id -un 2>/dev/null || printf unknown)" + + if id -nG "$current_user" 2>/dev/null | tr ' ' '\n' | grep -qx docker; then + return 0 + fi + if id -nG 2>/dev/null | tr ' ' '\n' | grep -qx docker; then + return 0 + fi + + info "Docker is reachable even though user '$current_user' is not in the docker group." + info "This host grants Docker daemon access through another path, so a negative test that expects 'docker info' to fail for non-docker users will not reproduce here." + if [ -n "${DOCKER_HOST:-}" ]; then + info "DOCKER_HOST is set to: $DOCKER_HOST" + else + info "DOCKER_HOST is not set; check for a docker wrapper, socket ACLs, sudo/policy rules, or host-specific daemon access configuration." + fi + local socket_state + socket_state="$(stat -Lc '%a %U %G %n' /var/run/docker.sock 2>/dev/null || true)" + if [ -n "$socket_state" ]; then + info "Docker socket: $socket_state" + fi +} + ensure_docker() { case "$(uname -s)" in Darwin | MINGW* | MSYS*) return 0 ;; @@ -2202,6 +2235,7 @@ ensure_docker() { fi # Fast path: docker info works → already set up (root, or already-active group). if docker info >/dev/null 2>&1; then + report_unexpected_docker_access return 0 fi diff --git a/test/e2e-scenario/framework-tests/e2e-live-project-config.test.ts b/test/e2e-scenario/framework-tests/e2e-live-project-config.test.ts index 208cab1a43..49d53cc335 100644 --- a/test/e2e-scenario/framework-tests/e2e-live-project-config.test.ts +++ b/test/e2e-scenario/framework-tests/e2e-live-project-config.test.ts @@ -26,6 +26,7 @@ interface RootConfig { const INSTALLER_INTEGRATION_TESTS = [ "test/install-preflight.test.ts", + "test/install-preflight-docker-bootstrap.test.ts", "test/install-openshell-version-check.test.ts", ]; const LIVE_E2E_SCENARIO_TESTS = ["test/e2e-scenario/live/**/*.test.ts"]; diff --git a/test/helpers/installer-sourced-env.ts b/test/helpers/installer-sourced-env.ts new file mode 100644 index 0000000000..c9c0ef0b1a --- /dev/null +++ b/test/helpers/installer-sourced-env.ts @@ -0,0 +1,52 @@ +// 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"; + +/** Path to the sourced installer payload (`scripts/install.sh`). */ +export const INSTALLER_PAYLOAD = path.join( + import.meta.dirname, + "..", + "..", + "scripts", + "install.sh", +); + +/** + * Build an isolated TEST_SYSTEM_PATH that mirrors /usr/bin and /bin while + * excluding node/npm/npx, so runtime preflight tests exercise missing-tool + * branches consistently across developer hosts and CI. Tests that need those + * tools prepend stubs from fakeBin; the tiny temp dir is left for OS cleanup. + */ +export function buildIsolatedSystemPath() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-preflight-sysbin-")); + const EXCLUDE = new Set(["node", "npm", "npx"]); + for (const sysDir of ["/usr/bin", "/bin"]) { + if (!fs.existsSync(sysDir)) continue; + for (const name of fs.readdirSync(sysDir)) { + if (EXCLUDE.has(name)) continue; + try { + fs.symlinkSync(path.join(sysDir, name), path.join(dir, name)); + } catch (err) { + // Only swallow EEXIST — the expected case is when /bin is a symlink + // to /usr/bin (modern Linux) and we already linked the same name on + // the first pass. Any other error (EPERM, EACCES, EINVAL, ENOENT…) + // would leave TEST_SYSTEM_PATH partially populated and turn into a + // confusing downstream test failure, so re-throw it. + const code = + typeof err === "object" && err !== null && "code" in err ? err.code : undefined; + if (code === "EEXIST") continue; + throw err; + } + } + } + return dir; +} + +export const TEST_SYSTEM_PATH = buildIsolatedSystemPath(); + +export function writeExecutable(target: string, contents: string) { + fs.writeFileSync(target, contents, { mode: 0o755 }); +} diff --git a/test/install-preflight-docker-bootstrap.test.ts b/test/install-preflight-docker-bootstrap.test.ts new file mode 100644 index 0000000000..b09b2e4f43 --- /dev/null +++ b/test/install-preflight-docker-bootstrap.test.ts @@ -0,0 +1,208 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + INSTALLER_PAYLOAD, + TEST_SYSTEM_PATH, + writeExecutable, +} from "./helpers/installer-sourced-env"; + +describe("installer Docker bootstrap (sourced)", () => { + function runEnsureDockerWithStubs({ + dockerScript, + idScript, + statScript, + systemctlScript = `#!/usr/bin/env bash +if [ "\${1:-}" = "is-active" ]; then exit 0; fi +if [ "\${1:-}" = "enable" ]; then exit 0; fi +exit 0 +`, + sudoScript = `#!/usr/bin/env bash +set -euo pipefail +if [ "\${1:-}" = "-n" ]; then shift; fi +printf '%s\\n' "$*" >> "$SUDO_LOG" +exec "$@" +`, + }: { + dockerScript: string; + idScript: string; + statScript?: string; + systemctlScript?: string; + sudoScript?: string; + }) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-docker-bootstrap-")); + const fakeBin = path.join(tmp, "bin"); + const sudoLog = path.join(tmp, "sudo.log"); + const idLog = path.join(tmp, "id.log"); + const dockerCount = path.join(tmp, "docker-count"); + fs.mkdirSync(fakeBin); + + writeExecutable(path.join(fakeBin, "docker"), dockerScript); + writeExecutable(path.join(fakeBin, "id"), idScript); + if (statScript) writeExecutable(path.join(fakeBin, "stat"), statScript); + writeExecutable(path.join(fakeBin, "sudo"), sudoScript); + writeExecutable(path.join(fakeBin, "systemctl"), systemctlScript); + writeExecutable( + path.join(fakeBin, "uname"), + `#!/usr/bin/env bash +printf 'Linux\\n' +`, + ); + + const result = spawnSync( + "bash", + [ + "-c", + ` +source "$INSTALLER_UNDER_TEST" >/dev/null +# These tests validate the Linux Docker bootstrap branches. On a real WSL +# runner the installer intentionally skips that bootstrap, so force the helper +# under test to behave as a non-WSL Linux host while keeping uname/id/docker +# stubbed through PATH. +is_wsl_host() { return 1; } +info() { printf 'INFO: %s\\n' "$*" >&2; } +warn() { printf 'WARN: %s\\n' "$*" >&2; } +error() { printf 'ERROR: %s\\n' "$*" >&2; exit 1; } +ensure_docker +`, + ], + { + cwd: tmp, + encoding: "utf-8", + env: { + HOME: tmp, + PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, + INSTALLER_UNDER_TEST: INSTALLER_PAYLOAD, + SUDO_LOG: sudoLog, + ID_LOG: idLog, + DOCKER_COUNT: dockerCount, + }, + }, + ); + + return { + result, + sudoLog: fs.existsSync(sudoLog) ? fs.readFileSync(sudoLog, "utf-8") : "", + idLog: fs.existsSync(idLog) ? fs.readFileSync(idLog, "utf-8") : "", + }; + } + + it("reports when Docker is reachable for a non-docker-group Linux user", () => { + const { result, sudoLog } = runEnsureDockerWithStubs({ + dockerScript: `#!/usr/bin/env bash +if [ "\${1:-}" = "info" ]; then exit 0; fi +exit 0 +`, + idScript: `#!/usr/bin/env bash +case "$*" in + "-u") printf '1000\\n' ;; + "-un") printf 'alice\\n' ;; + "-nG alice") printf 'alice sudo\\n' ;; + "-nG") printf 'alice sudo\\n' ;; + *) printf 'unexpected id %s\\n' "$*" >&2; exit 99 ;; +esac +`, + statScript: `#!/usr/bin/env bash +if [ "\${1:-}" = "-Lc" ]; then + printf '660 root docker /var/run/docker.sock\\n' + exit 0 +fi +exit 99 +`, + }); + + const output = `${result.stdout}${result.stderr}`; + expect(result.status, output).toBe(0); + expect(output).toMatch( + /Docker is reachable even though user 'alice' is not in the docker group/, + ); + expect(output).toMatch(/DOCKER_HOST/); + expect(output).toMatch(/660 root docker \/var\/run\/docker\.sock/); + expect(output).not.toMatch(/newgrp docker/); + expect(sudoLog).not.toMatch(/usermod/); + }); + + it("prompts for newgrp when persisted docker membership is not active", () => { + const { result, sudoLog } = runEnsureDockerWithStubs({ + dockerScript: `#!/usr/bin/env bash +if [ "\${1:-}" = "info" ]; then exit 1; fi +exit 0 +`, + idScript: `#!/usr/bin/env bash +case "$*" in + "-u") printf '1000\\n' ;; + "-un") printf 'alice\\n' ;; + "-nG alice") printf 'alice docker\\n' ;; + "-nG") printf 'alice adm\\n' ;; + *) printf 'unexpected id %s\\n' "$*" >&2; exit 99 ;; +esac +`, + }); + + const output = `${result.stdout}${result.stderr}`; + expect(result.status, output).toBe(0); + expect(output).toMatch(/Docker group membership is not active in this shell yet/); + expect(output).toMatch(/newgrp docker/); + expect(output).not.toMatch(/Docker is installed but not reachable/); + expect(sudoLog).not.toMatch(/usermod/); + }); + + it("reports daemon reachability when the active shell already has docker", () => { + const { result } = runEnsureDockerWithStubs({ + dockerScript: `#!/usr/bin/env bash +if [ "\${1:-}" = "info" ]; then exit 1; fi +exit 0 +`, + idScript: `#!/usr/bin/env bash +case "$*" in + "-u") printf '1000\\n' ;; + "-un") printf 'alice\\n' ;; + "-nG alice") printf 'alice docker\\n' ;; + "-nG") printf 'alice docker adm\\n' ;; + *) printf 'unexpected id %s\\n' "$*" >&2; exit 99 ;; +esac +`, + }); + + const output = `${result.stdout}${result.stderr}`; + expect(result.status, output).not.toBe(0); + expect(output).toMatch(/Docker is installed but not reachable/); + expect(output).toMatch(/sudo systemctl start docker/); + expect(output).not.toMatch(/newgrp docker/); + }); + + it("skips docker group membership checks for root", () => { + const { result, idLog } = runEnsureDockerWithStubs({ + dockerScript: `#!/usr/bin/env bash +if [ "\${1:-}" = "info" ]; then + count=0 + if [ -f "$DOCKER_COUNT" ]; then count="$(cat "$DOCKER_COUNT")"; fi + count=$((count + 1)) + printf '%s\\n' "$count" > "$DOCKER_COUNT" + if [ "$count" -ge 2 ]; then exit 0; fi + exit 1 +fi +exit 0 +`, + idScript: `#!/usr/bin/env bash +printf '%s\\n' "$*" >> "$ID_LOG" +case "$*" in + "-u") printf '0\\n' ;; + "-un") printf 'root\\n' ;; + "-nG"*) printf 'root should not check groups\\n' >&2; exit 99 ;; + *) printf 'unexpected id %s\\n' "$*" >&2; exit 99 ;; +esac +`, + }); + + const output = `${result.stdout}${result.stderr}`; + expect(result.status, output).toBe(0); + expect(idLog).toMatch(/^-u$/m); + expect(idLog).not.toMatch(/-nG/); + }); +}); diff --git a/test/install-preflight.test.ts b/test/install-preflight.test.ts index db4e6fb881..c4ad63170e 100644 --- a/test/install-preflight.test.ts +++ b/test/install-preflight.test.ts @@ -1,52 +1,20 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, it, expect } from "vitest"; +import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { spawnSync } from "node:child_process"; +import { describe, expect, it } from "vitest"; +import { + INSTALLER_PAYLOAD, + TEST_SYSTEM_PATH, + writeExecutable, +} from "./helpers/installer-sourced-env"; const INSTALLER = path.join(import.meta.dirname, "..", "install.sh"); const CURL_PIPE_INSTALLER = path.join(import.meta.dirname, "..", "install.sh"); -const INSTALLER_PAYLOAD = path.join(import.meta.dirname, "..", "scripts", "install.sh"); const GITHUB_INSTALL_URL = "git+https://github.com/NVIDIA/NemoClaw.git"; -/** - * Build an isolated TEST_SYSTEM_PATH that mirrors /usr/bin and /bin while - * excluding node/npm/npx, so runtime preflight tests exercise missing-tool - * branches consistently across developer hosts and CI. Tests that need those - * tools prepend stubs from fakeBin; the tiny temp dir is left for OS cleanup. - */ -function buildIsolatedSystemPath() { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-preflight-sysbin-")); - const EXCLUDE = new Set(["node", "npm", "npx"]); - for (const sysDir of ["/usr/bin", "/bin"]) { - if (!fs.existsSync(sysDir)) continue; - for (const name of fs.readdirSync(sysDir)) { - if (EXCLUDE.has(name)) continue; - try { - fs.symlinkSync(path.join(sysDir, name), path.join(dir, name)); - } catch (err) { - // Only swallow EEXIST — the expected case is when /bin is a symlink - // to /usr/bin (modern Linux) and we already linked the same name on - // the first pass. Any other error (EPERM, EACCES, EINVAL, ENOENT…) - // would leave TEST_SYSTEM_PATH partially populated and turn into a - // confusing downstream test failure, so re-throw it. - const code = - typeof err === "object" && err !== null && "code" in err ? err.code : undefined; - if (code === "EEXIST") continue; - throw err; - } - } - } - return dir; -} - -const TEST_SYSTEM_PATH = buildIsolatedSystemPath(); - -function writeExecutable(target: string, contents: string) { - fs.writeFileSync(target, contents, { mode: 0o755 }); -} /** Fake node that reports v22.16.0. */ function writeNodeStub(fakeBin: string) { @@ -3086,163 +3054,6 @@ exit 0`, }); }); -describe("installer Docker bootstrap (sourced)", () => { - function runEnsureDockerWithStubs({ - dockerScript, - idScript, - systemctlScript = `#!/usr/bin/env bash -if [ "\${1:-}" = "is-active" ]; then exit 0; fi -if [ "\${1:-}" = "enable" ]; then exit 0; fi -exit 0 -`, - sudoScript = `#!/usr/bin/env bash -set -euo pipefail -if [ "\${1:-}" = "-n" ]; then shift; fi -printf '%s\\n' "$*" >> "$SUDO_LOG" -exec "$@" -`, - }: { - dockerScript: string; - idScript: string; - systemctlScript?: string; - sudoScript?: string; - }) { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-docker-bootstrap-")); - const fakeBin = path.join(tmp, "bin"); - const sudoLog = path.join(tmp, "sudo.log"); - const idLog = path.join(tmp, "id.log"); - const dockerCount = path.join(tmp, "docker-count"); - fs.mkdirSync(fakeBin); - - writeExecutable(path.join(fakeBin, "docker"), dockerScript); - writeExecutable(path.join(fakeBin, "id"), idScript); - writeExecutable(path.join(fakeBin, "sudo"), sudoScript); - writeExecutable(path.join(fakeBin, "systemctl"), systemctlScript); - writeExecutable( - path.join(fakeBin, "uname"), - `#!/usr/bin/env bash -printf 'Linux\\n' -`, - ); - - const result = spawnSync( - "bash", - [ - "-c", - ` -source "$INSTALLER_UNDER_TEST" >/dev/null -# These tests validate the Linux Docker bootstrap branches. On a real WSL -# runner the installer intentionally skips that bootstrap, so force the helper -# under test to behave as a non-WSL Linux host while keeping uname/id/docker -# stubbed through PATH. -is_wsl_host() { return 1; } -info() { printf 'INFO: %s\\n' "$*" >&2; } -warn() { printf 'WARN: %s\\n' "$*" >&2; } -error() { printf 'ERROR: %s\\n' "$*" >&2; exit 1; } -ensure_docker -`, - ], - { - cwd: tmp, - encoding: "utf-8", - env: { - HOME: tmp, - PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, - INSTALLER_UNDER_TEST: INSTALLER_PAYLOAD, - SUDO_LOG: sudoLog, - ID_LOG: idLog, - DOCKER_COUNT: dockerCount, - }, - }, - ); - - return { - result, - sudoLog: fs.existsSync(sudoLog) ? fs.readFileSync(sudoLog, "utf-8") : "", - idLog: fs.existsSync(idLog) ? fs.readFileSync(idLog, "utf-8") : "", - }; - } - - it("prompts for newgrp when persisted docker membership is not active", () => { - const { result, sudoLog } = runEnsureDockerWithStubs({ - dockerScript: `#!/usr/bin/env bash -if [ "\${1:-}" = "info" ]; then exit 1; fi -exit 0 -`, - idScript: `#!/usr/bin/env bash -case "$*" in - "-u") printf '1000\\n' ;; - "-un") printf 'alice\\n' ;; - "-nG alice") printf 'alice docker\\n' ;; - "-nG") printf 'alice adm\\n' ;; - *) printf 'unexpected id %s\\n' "$*" >&2; exit 99 ;; -esac -`, - }); - - const output = `${result.stdout}${result.stderr}`; - expect(result.status, output).toBe(0); - expect(output).toMatch(/Docker group membership is not active in this shell yet/); - expect(output).toMatch(/newgrp docker/); - expect(output).not.toMatch(/Docker is installed but not reachable/); - expect(sudoLog).not.toMatch(/usermod/); - }); - - it("reports daemon reachability when the active shell already has docker", () => { - const { result } = runEnsureDockerWithStubs({ - dockerScript: `#!/usr/bin/env bash -if [ "\${1:-}" = "info" ]; then exit 1; fi -exit 0 -`, - idScript: `#!/usr/bin/env bash -case "$*" in - "-u") printf '1000\\n' ;; - "-un") printf 'alice\\n' ;; - "-nG alice") printf 'alice docker\\n' ;; - "-nG") printf 'alice docker adm\\n' ;; - *) printf 'unexpected id %s\\n' "$*" >&2; exit 99 ;; -esac -`, - }); - - const output = `${result.stdout}${result.stderr}`; - expect(result.status, output).not.toBe(0); - expect(output).toMatch(/Docker is installed but not reachable/); - expect(output).toMatch(/sudo systemctl start docker/); - expect(output).not.toMatch(/newgrp docker/); - }); - - it("skips docker group membership checks for root", () => { - const { result, idLog } = runEnsureDockerWithStubs({ - dockerScript: `#!/usr/bin/env bash -if [ "\${1:-}" = "info" ]; then - count=0 - if [ -f "$DOCKER_COUNT" ]; then count="$(cat "$DOCKER_COUNT")"; fi - count=$((count + 1)) - printf '%s\\n' "$count" > "$DOCKER_COUNT" - if [ "$count" -ge 2 ]; then exit 0; fi - exit 1 -fi -exit 0 -`, - idScript: `#!/usr/bin/env bash -printf '%s\\n' "$*" >> "$ID_LOG" -case "$*" in - "-u") printf '0\\n' ;; - "-un") printf 'root\\n' ;; - "-nG"*) printf 'root should not check groups\\n' >&2; exit 99 ;; - *) printf 'unexpected id %s\\n' "$*" >&2; exit 99 ;; -esac -`, - }); - - const output = `${result.stdout}${result.stderr}`; - expect(result.status, output).toBe(0); - expect(idLog).toMatch(/^-u$/m); - expect(idLog).not.toMatch(/-nG/); - }); -}); - describe("installer license acceptance (sourced)", () => { /** * Source scripts/install.sh and invoke show_usage_notice() in isolation. The diff --git a/vitest.config.ts b/vitest.config.ts index 2270f9afc2..0edcc70903 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -38,6 +38,7 @@ export default defineConfig({ "**/.claude/**", "test/e2e/**", "test/install-preflight.test.ts", + "test/install-preflight-docker-bootstrap.test.ts", "test/install-openshell-version-check.test.ts", ], }, @@ -46,7 +47,11 @@ export default defineConfig({ test: { name: "installer-integration", include: runInstallerIntegration - ? ["test/install-preflight.test.ts", "test/install-openshell-version-check.test.ts"] + ? [ + "test/install-preflight.test.ts", + "test/install-preflight-docker-bootstrap.test.ts", + "test/install-openshell-version-check.test.ts", + ] : [], // Slow tests that spawn real bash install.sh processes. // Run in CI or explicitly with: