Skip to content
2 changes: 1 addition & 1 deletion ci/test-file-size-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
34 changes: 34 additions & 0 deletions scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 ;;
Expand All @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Expand Down
52 changes: 52 additions & 0 deletions test/helpers/installer-sourced-env.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
208 changes: 208 additions & 0 deletions test/install-preflight-docker-bootstrap.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
Loading
Loading