From a0ffbd27e2fe2372343a34c74522d9470f97b03e Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Tue, 26 May 2026 07:02:09 +0800 Subject: [PATCH 1/5] fix(installer): report unexpected docker access Signed-off-by: Chengjie Wang --- scripts/install.sh | 34 ++++++++++++++++++++++++++++++++ test/install-preflight.test.ts | 36 ++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/scripts/install.sh b/scripts/install.sh index 1a5b5edc11..9bb317773e 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2098,6 +2098,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 ;; @@ -2107,6 +2140,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/install-preflight.test.ts b/test/install-preflight.test.ts index 5336a00d4c..45031592aa 100644 --- a/test/install-preflight.test.ts +++ b/test/install-preflight.test.ts @@ -2994,6 +2994,7 @@ 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 @@ -3008,6 +3009,7 @@ exec "$@" }: { dockerScript: string; idScript: string; + statScript?: string; systemctlScript?: string; sudoScript?: string; }) { @@ -3020,6 +3022,7 @@ exec "$@" 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( @@ -3067,6 +3070,39 @@ ensure_docker }; } + 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 From b033fa77dedafb070fee42a4ab3e2aaec62013a0 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 9 Jun 2026 19:13:40 -0700 Subject: [PATCH 2/5] style: biome-format install-preflight test + bump its size budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Biome-format the new docker-access test so static-checks' formatter hook leaves it unchanged, and raise the install-preflight.test.ts legacy line budget (4396→4434) to cover the added coverage. Formatting + budget only. Co-authored-by: chengjiew Signed-off-by: Prekshi Vyas Co-Authored-By: Claude Opus 4.8 (1M context) --- ci/test-file-size-budget.json | 2 +- test/install-preflight.test.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index d087e32d37..201af3dfbd 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": 4434, "test/nemoclaw-start.test.ts": 5289, "test/onboard-messaging.test.ts": 2097, "test/onboard-selection.test.ts": 6922, diff --git a/test/install-preflight.test.ts b/test/install-preflight.test.ts index eb63092bf8..cf6aeff80c 100644 --- a/test/install-preflight.test.ts +++ b/test/install-preflight.test.ts @@ -3192,7 +3192,9 @@ 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 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/); From c53f99297d7b1dae77872f62cfecd7000443b9f6 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 9 Jun 2026 19:52:22 -0700 Subject: [PATCH 3/5] Revert install-preflight budget bump (legacy budgets can't be raised) The growth guardrail only lets legacy test-file budgets ratchet down. The real fix is to relocate the new test out of the pinned file; reverting to 4396 so the CI surfaces the honest 'above budget' signal. Signed-off-by: Prekshi Vyas Co-Authored-By: Claude Opus 4.8 (1M context) --- ci/test-file-size-budget.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index 201af3dfbd..d087e32d37 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": 4434, + "test/install-preflight.test.ts": 4396, "test/nemoclaw-start.test.ts": 5289, "test/onboard-messaging.test.ts": 2097, "test/onboard-selection.test.ts": 6922, From e5827206b504537efc45a61241d45e8d305f4827 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Wed, 10 Jun 2026 10:21:54 -0700 Subject: [PATCH 4/5] test(installer): extract Docker-bootstrap tests to keep install-preflight within its legacy line budget The new non-docker-group test pushed test/install-preflight.test.ts over its pinned legacy size budget (4396), which the growth guardrail forbids raising. Move the whole 'installer Docker bootstrap (sourced)' describe block to a new test/install-preflight-docker-bootstrap.test.ts, and share the sourced-env helpers (INSTALLER_PAYLOAD/TEST_SYSTEM_PATH/writeExecutable/buildIsolatedSystemPath) via test/helpers/installer-sourced-env.ts. Wire the new file into the gated installer-integration vitest project (not cli) so it runs exactly where the originals did. install-preflight.test.ts drops to 4207; budget lowered to match. Signed-off-by: Prekshi Vyas Co-Authored-By: Claude Opus 4.8 (1M context) --- ci/test-file-size-budget.json | 2 +- test/helpers/installer-sourced-env.ts | 52 ++++ ...install-preflight-docker-bootstrap.test.ts | 208 +++++++++++++++ test/install-preflight.test.ts | 241 +----------------- vitest.config.ts | 7 +- 5 files changed, 274 insertions(+), 236 deletions(-) create mode 100644 test/helpers/installer-sourced-env.ts create mode 100644 test/install-preflight-docker-bootstrap.test.ts 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/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 cf6aeff80c..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,201 +3054,6 @@ exit 0`, }); }); -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/); - }); -}); - 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: From e79fc652b31b7eb77b5550c1d1eb5dae926d78a5 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Wed, 10 Jun 2026 10:28:23 -0700 Subject: [PATCH 5/5] test: register install-preflight-docker-bootstrap in the installer-integration config guard The gated-project config test asserts the exact installer-integration include list; add the new bootstrap test file so the guard matches vitest.config.ts. Signed-off-by: Prekshi Vyas Co-Authored-By: Claude Opus 4.8 (1M context) --- .../e2e-scenario/framework-tests/e2e-live-project-config.test.ts | 1 + 1 file changed, 1 insertion(+) 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"];