diff --git a/.github/workflows/nightly-e2e.yaml b/.github/workflows/nightly-e2e.yaml index bc561b0d1..c15f11914 100644 --- a/.github/workflows/nightly-e2e.yaml +++ b/.github/workflows/nightly-e2e.yaml @@ -49,6 +49,7 @@ jobs: env: NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }} NEMOCLAW_NON_INTERACTIVE: "1" + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1" NEMOCLAW_SANDBOX_NAME: "e2e-nightly" NEMOCLAW_RECREATE_SANDBOX: "1" GITHUB_TOKEN: ${{ github.token }} @@ -80,6 +81,7 @@ jobs: # Non-interactive install (expect-driven Phase 3 optional). Runner has no expect; Phase 5e TUI skips if expect is absent. RUN_E2E_CLOUD_EXPERIMENTAL_INTERACTIVE_INSTALL: "0" NEMOCLAW_NON_INTERACTIVE: "1" + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1" NEMOCLAW_RECREATE_SANDBOX: "1" NEMOCLAW_POLICY_MODE: "custom" NEMOCLAW_POLICY_PRESETS: "npm,pypi" @@ -171,6 +173,7 @@ jobs: timeout-minutes: 60 env: NEMOCLAW_NON_INTERACTIVE: "1" + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1" NEMOCLAW_SANDBOX_NAME: "e2e-gpu-ollama" NEMOCLAW_RECREATE_SANDBOX: "1" NEMOCLAW_PROVIDER: "ollama" diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index d67075e41..30ec69d7f 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -53,6 +53,7 @@ const registry = require("./registry"); const nim = require("./nim"); const onboardSession = require("./onboard-session"); const policies = require("./policies"); +const { ensureUsageNoticeConsent } = require("./usage-notice"); const { checkPortAvailable, ensureSwap, getMemoryInfo } = require("./preflight"); // Typed modules (compiled from src/lib/*.ts → dist/lib/*.js) @@ -3450,6 +3451,14 @@ async function onboard(opts = {}) { NON_INTERACTIVE = opts.nonInteractive || process.env.NEMOCLAW_NON_INTERACTIVE === "1"; delete process.env.OPENSHELL_GATEWAY; const resume = opts.resume === true; + const noticeAccepted = await ensureUsageNoticeConsent({ + nonInteractive: isNonInteractive(), + acceptedByFlag: opts.acceptThirdPartySoftware === true, + writeLine: console.error, + }); + if (!noticeAccepted) { + process.exit(1); + } const lockResult = onboardSession.acquireOnboardLock( `nemoclaw onboard${resume ? " --resume" : ""}${isNonInteractive() ? " --non-interactive" : ""}`, ); diff --git a/bin/lib/usage-notice.js b/bin/lib/usage-notice.js new file mode 100644 index 000000000..6980825a4 --- /dev/null +++ b/bin/lib/usage-notice.js @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Thin re-export shim — the implementation lives in src/lib/usage-notice.ts, +// compiled to dist/lib/usage-notice.js. +const usageNotice = require("../../dist/lib/usage-notice"); + +if (require.main === module) { + usageNotice.cli().catch((error) => { + console.error(error?.message || String(error)); + process.exit(1); + }); +} + +module.exports = usageNotice; diff --git a/bin/lib/usage-notice.json b/bin/lib/usage-notice.json new file mode 100644 index 000000000..782ddc400 --- /dev/null +++ b/bin/lib/usage-notice.json @@ -0,0 +1,32 @@ +{ + "version": "2026-04-01b", + "title": "Third-Party Software Notice - NemoClaw Installer", + "referenceUrl": "https://docs.openclaw.ai/gateway/security", + "body": [ + "NemoClaw is licensed under Apache 2.0 and automatically", + "retrieves, accesses or interacts with third-party software", + "and materials, including by deploying OpenClaw in an", + "OpenShell sandbox. Those retrieved materials are not", + "distributed with this software and are governed solely", + "by separate terms, conditions and licenses.", + "", + "You are solely responsible for finding, reviewing and", + "complying with all applicable terms, conditions, and", + "licenses, and for verifying the security, integrity and", + "suitability of any retrieved materials for your specific", + "use case.", + "", + "This software is provided \"AS IS\", without warranty of", + "any kind. The author makes no representations or", + "warranties regarding any third-party software, and", + "assumes no liability for any losses, damages, liabilities", + "or legal consequences from your use or inability to use", + "this software or any retrieved materials. Use this", + "software and the retrieved materials at your own risk.", + "", + "OpenClaw security guidance", + "https://docs.openclaw.ai/gateway/security" + ], + "links": [], + "interactivePrompt": "Type 'yes' to accept the NemoClaw license and and third-party software notice and continue [no]: " +} diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index ba21a2aa4..0d20c5e41 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -45,6 +45,7 @@ const { parseGatewayInference } = require("./lib/inference-config"); const { getVersion } = require("./lib/version"); const onboardSession = require("./lib/onboard-session"); const { parseLiveSandboxNames } = require("./lib/runtime-recovery"); +const { NOTICE_ACCEPT_ENV, NOTICE_ACCEPT_FLAG } = require("./lib/usage-notice"); // ── Global commands ────────────────────────────────────────────── @@ -622,16 +623,20 @@ function exitWithSpawnResult(result) { async function onboard(args) { const { onboard: runOnboard } = require("./lib/onboard"); - const allowedArgs = new Set(["--non-interactive", "--resume"]); + const allowedArgs = new Set(["--non-interactive", "--resume", NOTICE_ACCEPT_FLAG]); const unknownArgs = args.filter((arg) => !allowedArgs.has(arg)); if (unknownArgs.length > 0) { console.error(` Unknown onboard option(s): ${unknownArgs.join(", ")}`); - console.error(" Usage: nemoclaw onboard [--non-interactive] [--resume]"); + console.error( + ` Usage: nemoclaw onboard [--non-interactive] [--resume] [${NOTICE_ACCEPT_FLAG}]`, + ); process.exit(1); } const nonInteractive = args.includes("--non-interactive"); const resume = args.includes("--resume"); - await runOnboard({ nonInteractive, resume }); + const acceptThirdPartySoftware = + args.includes(NOTICE_ACCEPT_FLAG) || String(process.env[NOTICE_ACCEPT_ENV] || "") === "1"; + await runOnboard({ nonInteractive, resume, acceptThirdPartySoftware }); } async function setup(args = []) { @@ -1154,6 +1159,7 @@ function help() { ${G}Getting Started:${R} ${B}nemoclaw onboard${R} Configure inference endpoint and credentials + ${D}(non-interactive: ${NOTICE_ACCEPT_FLAG} or ${NOTICE_ACCEPT_ENV}=1)${R} nemoclaw setup-spark Set up on DGX Spark ${D}(fixes cgroup v2 + Docker)${R} ${G}Sandbox Management:${R} diff --git a/docs/reference/commands.md b/docs/reference/commands.md index f83f7796f..46cc546ef 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -69,6 +69,18 @@ Supported non-experimental choices include NVIDIA Endpoints, OpenAI, Anthropic, Credentials are stored in `~/.nemoclaw/credentials.json`. The legacy `nemoclaw setup` command is deprecated; use `nemoclaw onboard` instead. +For non-interactive onboarding, you must explicitly accept the third-party software notice: + +```console +$ nemoclaw onboard --non-interactive --yes-i-accept-third-party-software +``` + +or: + +```console +$ NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 nemoclaw onboard --non-interactive +``` + The wizard prompts for a sandbox name. Names must follow RFC 1123 subdomain rules: lowercase alphanumeric characters and hyphens only, and must start and end with an alphanumeric character. Uppercase letters are automatically lowercased. diff --git a/install.sh b/install.sh index a5282e440..896e024a1 100755 --- a/install.sh +++ b/install.sh @@ -217,10 +217,12 @@ usage() { printf " curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash -s -- [options]\n\n" printf " ${C_DIM}Options:${C_RESET}\n" printf " --non-interactive Skip prompts (uses env vars / defaults)\n" + printf " --yes-i-accept-third-party-software Accept the third-party software notice in non-interactive mode\n" printf " --version, -v Print installer version and exit\n" printf " --help, -h Show this help message and exit\n\n" printf " ${C_DIM}Environment:${C_RESET}\n" printf " NVIDIA_API_KEY API key (skips credential prompt)\n" + printf " NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 Same as --yes-i-accept-third-party-software\n" printf " NEMOCLAW_NON_INTERACTIVE=1 Same as --non-interactive\n" printf " NEMOCLAW_SANDBOX_NAME Sandbox name to create/use\n" printf " NEMOCLAW_RECREATE_SANDBOX=1 Recreate an existing sandbox\n" @@ -237,6 +239,27 @@ usage() { printf "\n" } +show_usage_notice() { + local -a notice_cmd=(node "${SCRIPT_DIR}/bin/lib/usage-notice.js") + if [ "${NON_INTERACTIVE:-}" = "1" ]; then + notice_cmd+=(--non-interactive) + if [ "${ACCEPT_THIRD_PARTY_SOFTWARE:-}" = "1" ]; then + notice_cmd+=(--yes-i-accept-third-party-software) + fi + "${notice_cmd[@]}" + elif [ -t 0 ]; then + "${notice_cmd[@]}" + elif exec 3 Promise; +type WriteLineFn = (line: string) => void; + +type EnsureUsageNoticeConsentOptions = { + nonInteractive?: boolean; + acceptedByFlag?: boolean; + promptFn?: PromptFn | null; + writeLine?: WriteLineFn; +}; + +export function getUsageNoticeStateFile(): string { + return path.join(process.env.HOME || os.homedir(), ".nemoclaw", "usage-notice.json"); +} + +export function loadUsageNoticeConfig(): NoticeConfig { + return noticeConfig as NoticeConfig; +} + +export function hasAcceptedUsageNotice(version: string): boolean { + try { + const saved = JSON.parse(fs.readFileSync(getUsageNoticeStateFile(), "utf8")) as { + acceptedVersion?: string; + }; + return saved?.acceptedVersion === version; + } catch { + return false; + } +} + +export function saveUsageNoticeAcceptance(version: string): void { + const stateFile = getUsageNoticeStateFile(); + const dir = path.dirname(stateFile); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + fs.chmodSync(dir, 0o700); + fs.writeFileSync( + stateFile, + JSON.stringify({ acceptedVersion: version, acceptedAt: new Date().toISOString() }, null, 2), + { mode: 0o600 }, + ); + fs.chmodSync(stateFile, 0o600); +} + +export function supportsTerminalHyperlinks(): boolean { + const tty = process.stderr?.isTTY || process.stdout?.isTTY; + if (!tty) return false; + if (process.env.NO_COLOR) return false; + if (process.env.TERM === "dumb") return false; + return true; +} + +export function formatTerminalHyperlink(label: string, url: string): string { + return `${OSC8_OPEN}${url}${OSC8_TERM}${label}${OSC8_CLOSE}`; +} + +export function printUsageNotice( + config: NoticeConfig = loadUsageNoticeConfig(), + writeLine: WriteLineFn = console.error, +): void { + writeLine(""); + writeLine(` ${config.title}`); + writeLine(" ──────────────────────────────────────────────────"); + for (const line of config.body || []) { + const renderedLine = + /^https?:\/\//.test(line) && supportsTerminalHyperlinks() + ? formatTerminalHyperlink(line, line) + : line; + writeLine(` ${renderedLine}`); + } + for (const link of config.links || []) { + writeLine(""); + const label = + supportsTerminalHyperlinks() && link?.url && link?.label + ? formatTerminalHyperlink(link.url, link.url) + : link?.label || ""; + if (label) { + writeLine(` ${label}`); + } + if (link?.url) { + writeLine(` ${link.url}`); + } + } + writeLine(""); +} + +export async function ensureUsageNoticeConsent({ + nonInteractive = false, + acceptedByFlag = false, + promptFn = null, + writeLine = console.error, +}: EnsureUsageNoticeConsentOptions = {}): Promise { + const config = loadUsageNoticeConfig(); + if (hasAcceptedUsageNotice(config.version)) { + return true; + } + + printUsageNotice(config, writeLine); + + if (nonInteractive) { + if (!acceptedByFlag) { + writeLine( + ` Non-interactive onboarding requires ${NOTICE_ACCEPT_FLAG} or ${NOTICE_ACCEPT_ENV}=1.`, + ); + return false; + } + writeLine( + ` [non-interactive] Third-party software notice accepted via ${NOTICE_ACCEPT_FLAG}.`, + ); + saveUsageNoticeAcceptance(config.version); + return true; + } + + if (!process.stdin.isTTY) { + writeLine( + ` Interactive onboarding requires a TTY. Re-run in a terminal or use --non-interactive with ${NOTICE_ACCEPT_FLAG}.`, + ); + return false; + } + + // credentials is still CJS + // eslint-disable-next-line @typescript-eslint/no-require-imports + const ask = promptFn || require("../../bin/lib/credentials").prompt; + const answer = String(await ask(` ${config.interactivePrompt}`)) + .trim() + .toLowerCase(); + if (answer !== "yes") { + writeLine(" Installation cancelled"); + return false; + } + + saveUsageNoticeAcceptance(config.version); + return true; +} + +export async function cli(args = process.argv.slice(2)): Promise { + const acceptedByFlag = + args.includes(NOTICE_ACCEPT_FLAG) || String(process.env[NOTICE_ACCEPT_ENV] || "") === "1"; + const nonInteractive = args.includes("--non-interactive"); + const ok = await ensureUsageNoticeConsent({ + nonInteractive, + acceptedByFlag, + writeLine: console.error, + }); + process.exit(ok ? 0 : 1); +} diff --git a/test/cli.test.js b/test/cli.test.js index 0e6fc6645..c3cf39118 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -126,6 +126,12 @@ describe("CLI dispatch", () => { expect(r.out.includes("Unknown onboard option(s): --non-interactiv")).toBeTruthy(); }); + it("accepts the third-party software flag in onboard CLI parsing", () => { + const r = run("onboard --yes-i-accept-third-party-software --non-interactiv"); + expect(r.code).toBe(1); + expect(r.out.includes("Unknown onboard option(s): --non-interactiv")).toBeTruthy(); + }); + it("setup forwards unknown options into onboard parsing", () => { const r = run("setup --non-interactiv"); expect(r.code).toBe(1); @@ -134,7 +140,7 @@ describe("CLI dispatch", () => { }); it("setup forwards --resume into onboard parsing", () => { - const r = run("setup --resume"); + const r = run("setup --resume --non-interactive --yes-i-accept-third-party-software"); expect(r.code).toBe(1); expect(r.out.includes("deprecated")).toBeTruthy(); expect(r.out.includes("No resumable onboarding session was found")).toBeTruthy(); diff --git a/test/e2e/brev-e2e.test.js b/test/e2e/brev-e2e.test.js index 858d30ce9..d34b8c6a7 100644 --- a/test/e2e/brev-e2e.test.js +++ b/test/e2e/brev-e2e.test.js @@ -72,6 +72,7 @@ function sshEnv(cmd, { timeout = 600_000, stream = false } = {}) { `export NVIDIA_API_KEY='${shellEscape(process.env.NVIDIA_API_KEY)}'`, `export GITHUB_TOKEN='${shellEscape(process.env.GITHUB_TOKEN)}'`, `export NEMOCLAW_NON_INTERACTIVE=1`, + `export NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1`, `export NEMOCLAW_SANDBOX_NAME=e2e-test`, ].join(" && "); diff --git a/test/e2e/e2e-cloud-experimental/test-port8080-conflict.sh b/test/e2e/e2e-cloud-experimental/test-port8080-conflict.sh index f10392c8d..b35a4d000 100755 --- a/test/e2e/e2e-cloud-experimental/test-port8080-conflict.sh +++ b/test/e2e/e2e-cloud-experimental/test-port8080-conflict.sh @@ -18,12 +18,13 @@ # 2 — skipped (no python3/python to bind 8080) # # Environment (typical): -# NEMOCLAW_SANDBOX_NAME — default: e2e-cloud-experimental -# NEMOCLAW_NON_INTERACTIVE — should be 1 (onboard non-interactive) -# NVIDIA_API_KEY — required if onboard reaches cloud inference (restore path) +# NEMOCLAW_SANDBOX_NAME — default: e2e-cloud-experimental +# NEMOCLAW_NON_INTERACTIVE — should be 1 (onboard non-interactive) +# NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 — required for non-interactive onboard/re-onboard +# NVIDIA_API_KEY — required if onboard reaches cloud inference (restore path) # # Usage: -# NEMOCLAW_NON_INTERACTIVE=1 NVIDIA_API_KEY=nvapi-... bash test/e2e/test-port8080-conflict.sh +# NEMOCLAW_NON_INTERACTIVE=1 NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 NVIDIA_API_KEY=nvapi-... bash test/e2e/e2e-cloud-experimental/test-port8080-conflict.sh set -uo pipefail @@ -74,7 +75,7 @@ PASS "Port 8080 occupied by dummy process (PID ${occupier_pid})" P4_LOG="$(mktemp)" INFO "Running nemoclaw onboard --non-interactive (expect preflight to fail on port 8080)..." set +e -nemoclaw onboard --non-interactive >"$P4_LOG" 2>&1 +NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 nemoclaw onboard --non-interactive >"$P4_LOG" 2>&1 p4_exit=$? set -euo pipefail p4_out="$(cat "$P4_LOG")" @@ -123,7 +124,7 @@ else INFO "Sandbox missing after gateway destroy/recreate — re-onboarding with NEMOCLAW_RECREATE_SANDBOX=1..." P4R_LOG="$(mktemp)" set +e - NEMOCLAW_RECREATE_SANDBOX=1 nemoclaw onboard --non-interactive >"$P4R_LOG" 2>&1 + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 NEMOCLAW_RECREATE_SANDBOX=1 nemoclaw onboard --non-interactive >"$P4R_LOG" 2>&1 p4r_exit=$? set -euo pipefail if [ "$p4r_exit" -ne 0 ]; then diff --git a/test/e2e/test-credential-sanitization.sh b/test/e2e/test-credential-sanitization.sh index 8c519e55b..f86aecda0 100755 --- a/test/e2e/test-credential-sanitization.sh +++ b/test/e2e/test-credential-sanitization.sh @@ -25,7 +25,7 @@ # NVIDIA_API_KEY — required # # Usage: -# NEMOCLAW_NON_INTERACTIVE=1 NVIDIA_API_KEY=nvapi-... bash test/e2e/test-credential-sanitization.sh +# NEMOCLAW_NON_INTERACTIVE=1 NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 NVIDIA_API_KEY=nvapi-... bash test/e2e/test-credential-sanitization.sh # # See: https://github.com/NVIDIA/NemoClaw/pull/156 diff --git a/test/e2e/test-double-onboard.sh b/test/e2e/test-double-onboard.sh index da2f4a065..a9a99e8d7 100755 --- a/test/e2e/test-double-onboard.sh +++ b/test/e2e/test-double-onboard.sh @@ -151,6 +151,7 @@ run_onboard() { local -a env_args=( "COMPATIBLE_API_KEY=dummy" "NEMOCLAW_NON_INTERACTIVE=1" + "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1" "NEMOCLAW_PROVIDER=custom" "NEMOCLAW_ENDPOINT_URL=${FAKE_BASE_URL}" "NEMOCLAW_MODEL=test-model" diff --git a/test/e2e/test-e2e-cloud-experimental.sh b/test/e2e/test-e2e-cloud-experimental.sh index 2322b5b64..18b3110a2 100755 --- a/test/e2e/test-e2e-cloud-experimental.sh +++ b/test/e2e/test-e2e-cloud-experimental.sh @@ -8,7 +8,7 @@ # Implemented: Phase 0–1, 3, 5–6. Phase 5 runs checks/*.sh; Phase 5b live chat; Phase 5c skill smoke; Phase 5d skill agent verification; Phase 5f check-docs.sh; # Phase 5e openclaw TUI smoke (expect, non-interactive); Phase 5f check-docs.sh; Phase 6 final cleanup. # Phase 3 default: expect-driven interactive curl|bash (RUN_E2E_CLOUD_EXPERIMENTAL_INTERACTIVE_INSTALL=1). -# Set RUN_E2E_CLOUD_EXPERIMENTAL_INTERACTIVE_INSTALL=0 for non-interactive install (NEMOCLAW_NON_INTERACTIVE=1, no expect). +# Set RUN_E2E_CLOUD_EXPERIMENTAL_INTERACTIVE_INSTALL=0 for non-interactive install (NEMOCLAW_NON_INTERACTIVE=1 and NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1, no expect). # (add checks under e2e-cloud-experimental/checks without editing case loop). VDR3 #12 via env on Phase 3 install. # Phase 2 skipped. Phase 5: checks suite (checks/*.sh only; opt-in scripts live under e2e-cloud-experimental/skip/). # Phase 5b: POST /v1/chat/completions inside sandbox (model = CLOUD_EXPERIMENTAL_MODEL); retries on transient gateway/upstream failures. @@ -36,6 +36,7 @@ # - NVIDIA_API_KEY set (nvapi-...) for Cloud inference segments # - Network to integrate.api.nvidia.com # - NEMOCLAW_NON_INTERACTIVE=1 for automated onboard segments +# - NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 for automated non-interactive install/onboard segments # # Environment (suggested): # Sandbox name is fixed in this script: e2e-cloud-experimental @@ -47,7 +48,7 @@ # NEMOCLAW_POLICY_PRESETS — e.g. npm,pypi (github preset TBD in repo) # RUN_E2E_CLOUD_EXPERIMENTAL_INTERACTIVE=1 — optional: expect-based steps (later phases) # RUN_E2E_CLOUD_EXPERIMENTAL_INTERACTIVE_INSTALL — default 1: Phase 3 uses expect to drive interactive onboard. -# Set to 0 for non-interactive curl|bash (requires NEMOCLAW_NON_INTERACTIVE=1 in host env; no expect on PATH). +# Set to 0 for non-interactive curl|bash (requires NEMOCLAW_NON_INTERACTIVE=1 and NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 in host env; no expect on PATH). # INTERACTIVE_SANDBOX_NAME / INTERACTIVE_RECREATE_ANSWER / INTERACTIVE_INFERENCE_SEND / INTERACTIVE_MODEL_SEND / INTERACTIVE_PRESETS_SEND — see Phase 3 expect branch # DEMO_FAKE_ONLY=1 — expect-only smoke, exit before Phase 0 (offline) # RUN_E2E_CLOUD_EXPERIMENTAL_TUI=0 — skip Phase 5e (openclaw tui expect smoke) @@ -73,7 +74,7 @@ # # Usage (Phases 0–1, 3 + cases + Phase 5b–5f + Phase 6 cleanup; Phase 2 skipped): # NVIDIA_API_KEY=nvapi-... bash test/e2e/test-e2e-cloud-experimental.sh -# Non-interactive install (no expect): RUN_E2E_CLOUD_EXPERIMENTAL_INTERACTIVE_INSTALL=0 NEMOCLAW_NON_INTERACTIVE=1 NVIDIA_API_KEY=nvapi-... bash ... +# Non-interactive install (no expect): RUN_E2E_CLOUD_EXPERIMENTAL_INTERACTIVE_INSTALL=0 NEMOCLAW_NON_INTERACTIVE=1 NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 NVIDIA_API_KEY=nvapi-... bash ... # # Validate only (existing sandbox; no install, no Phase 0/6 teardown): # RUN_E2E_CLOUD_EXPERIMENTAL_FROM_PHASE5=1 NVIDIA_API_KEY=nvapi-... bash test/e2e/test-e2e-cloud-experimental.sh @@ -245,7 +246,7 @@ fi # Phase 1: Prerequisites # ══════════════════════════════════════════════════════════════════════ # Docker running; NVIDIA_API_KEY format; reach integrate.api.nvidia.com; -# NEMOCLAW_NON_INTERACTIVE=1 for automated path; optional: assert Linux + Docker CE. +# NEMOCLAW_NON_INTERACTIVE=1 and NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 for automated path; optional: assert Linux + Docker CE. section "Phase 1: Prerequisites" if ! e2e_cloud_experimental_phase_enabled phase1; then @@ -274,8 +275,12 @@ elif docker info >/dev/null 2>&1; then elif [ "${NEMOCLAW_NON_INTERACTIVE:-}" != "1" ]; then fail "NEMOCLAW_NON_INTERACTIVE=1 is required when RUN_E2E_CLOUD_EXPERIMENTAL_INTERACTIVE_INSTALL=0 (or use default interactive install, or RUN_E2E_CLOUD_EXPERIMENTAL_FROM_PHASE5=1)" exit 1 + elif [ "${NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE:-}" != "1" ]; then + fail "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 is required when RUN_E2E_CLOUD_EXPERIMENTAL_INTERACTIVE_INSTALL=0" + exit 1 else pass "NEMOCLAW_NON_INTERACTIVE=1" + pass "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1" fi # Nominal scenario: Ubuntu + Docker (Linux + Docker in README). Others may still run; do not hard-fail on macOS. diff --git a/test/e2e/test-full-e2e.sh b/test/e2e/test-full-e2e.sh index 4c2f9a46e..275a6ed16 100755 --- a/test/e2e/test-full-e2e.sh +++ b/test/e2e/test-full-e2e.sh @@ -14,13 +14,14 @@ # - Network access to integrate.api.nvidia.com # # Environment variables: -# NEMOCLAW_NON_INTERACTIVE=1 — required (enables non-interactive install + onboard) -# NEMOCLAW_SANDBOX_NAME — sandbox name (default: e2e-nightly) -# NEMOCLAW_RECREATE_SANDBOX=1 — recreate sandbox if it exists from a previous run -# NVIDIA_API_KEY — required for NVIDIA Endpoints inference +# NEMOCLAW_NON_INTERACTIVE=1 — required (enables non-interactive install + onboard) +# NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 — required for non-interactive install/onboard +# NEMOCLAW_SANDBOX_NAME — sandbox name (default: e2e-nightly) +# NEMOCLAW_RECREATE_SANDBOX=1 — recreate sandbox if it exists from a previous run +# NVIDIA_API_KEY — required for NVIDIA Endpoints inference # # Usage: -# NEMOCLAW_NON_INTERACTIVE=1 NVIDIA_API_KEY=nvapi-... bash test/e2e/test-full-e2e.sh +# NEMOCLAW_NON_INTERACTIVE=1 NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 NVIDIA_API_KEY=nvapi-... bash test/e2e/test-full-e2e.sh # # See: https://github.com/NVIDIA/NemoClaw/issues/71 @@ -125,6 +126,11 @@ if [ "${NEMOCLAW_NON_INTERACTIVE:-}" != "1" ]; then exit 1 fi +if [ "${NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE:-}" != "1" ]; then + fail "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 is required for non-interactive install" + exit 1 +fi + # ══════════════════════════════════════════════════════════════════ # Phase 2: Install nemoclaw (non-interactive mode) # ══════════════════════════════════════════════════════════════════ diff --git a/test/e2e/test-gpu-e2e.sh b/test/e2e/test-gpu-e2e.sh index f0b2bbc6a..6d034b964 100755 --- a/test/e2e/test-gpu-e2e.sh +++ b/test/e2e/test-gpu-e2e.sh @@ -17,18 +17,20 @@ # - NVIDIA GPU with drivers (nvidia-smi works) # - Docker # - NEMOCLAW_NON_INTERACTIVE=1 +# - NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 # - Internet access (ollama.com for install, registry.ollama.ai for model pull) # - No existing Ollama service on port 11434 (ephemeral runners are ideal) # # Environment variables: -# NEMOCLAW_NON_INTERACTIVE=1 — required -# NEMOCLAW_SANDBOX_NAME — sandbox name (default: e2e-gpu-ollama) -# NEMOCLAW_RECREATE_SANDBOX=1 — recreate sandbox if it exists -# NEMOCLAW_MODEL — model for onboard (default: auto-selected by onboard) -# SKIP_UNINSTALL — set to 1 to skip uninstall (debugging) +# NEMOCLAW_NON_INTERACTIVE=1 — required +# NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 — required for non-interactive install/onboard +# NEMOCLAW_SANDBOX_NAME — sandbox name (default: e2e-gpu-ollama) +# NEMOCLAW_RECREATE_SANDBOX=1 — recreate sandbox if it exists +# NEMOCLAW_MODEL — model for onboard (default: auto-selected by onboard) +# SKIP_UNINSTALL — set to 1 to skip uninstall (debugging) # # Usage: -# NEMOCLAW_NON_INTERACTIVE=1 bash test/e2e/test-gpu-e2e.sh +# NEMOCLAW_NON_INTERACTIVE=1 NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 bash test/e2e/test-gpu-e2e.sh set -uo pipefail @@ -152,6 +154,11 @@ if [ "${NEMOCLAW_NON_INTERACTIVE:-}" != "1" ]; then exit 1 fi +if [ "${NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE:-}" != "1" ]; then + fail "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 is required for non-interactive install" + exit 1 +fi + # Verify port 11434 is free (onboard needs to start Ollama on 0.0.0.0:11434) if curl -sf http://localhost:11434/api/tags >/dev/null 2>&1; then info "WARNING: Something is already listening on port 11434." diff --git a/test/e2e/test-onboard-repair.sh b/test/e2e/test-onboard-repair.sh index 5e14763e1..199c8b74c 100755 --- a/test/e2e/test-onboard-repair.sh +++ b/test/e2e/test-onboard-repair.sh @@ -126,6 +126,7 @@ info "Running onboard with an invalid policy mode to create resumable state..." FIRST_LOG="$(mktemp)" NEMOCLAW_NON_INTERACTIVE=1 \ + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 \ NEMOCLAW_SANDBOX_NAME="$SANDBOX_NAME" \ NEMOCLAW_RECREATE_SANDBOX=1 \ NEMOCLAW_POLICY_MODE=invalid \ @@ -178,6 +179,7 @@ fi REPAIR_LOG="$(mktemp)" env -u NVIDIA_API_KEY \ NEMOCLAW_NON_INTERACTIVE=1 \ + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 \ NEMOCLAW_SANDBOX_NAME="$SANDBOX_NAME" \ NEMOCLAW_POLICY_MODE=skip \ node "$REPO/bin/nemoclaw.js" onboard --resume --non-interactive >"$REPAIR_LOG" 2>&1 @@ -232,6 +234,7 @@ info "Attempting resume with a different sandbox name..." SANDBOX_CONFLICT_LOG="$(mktemp)" env -u NVIDIA_API_KEY \ NEMOCLAW_NON_INTERACTIVE=1 \ + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 \ NEMOCLAW_SANDBOX_NAME="$OTHER_SANDBOX_NAME" \ NEMOCLAW_POLICY_MODE=skip \ node "$REPO/bin/nemoclaw.js" onboard --resume --non-interactive >"$SANDBOX_CONFLICT_LOG" 2>&1 @@ -260,6 +263,7 @@ info "Attempting resume with conflicting provider/model inputs..." PROVIDER_CONFLICT_LOG="$(mktemp)" env -u NVIDIA_API_KEY \ NEMOCLAW_NON_INTERACTIVE=1 \ + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 \ NEMOCLAW_SANDBOX_NAME="$SANDBOX_NAME" \ NEMOCLAW_PROVIDER=openai \ NEMOCLAW_MODEL=gpt-5.4 \ diff --git a/test/e2e/test-onboard-resume.sh b/test/e2e/test-onboard-resume.sh index 2ccef1fc3..6760aa093 100755 --- a/test/e2e/test-onboard-resume.sh +++ b/test/e2e/test-onboard-resume.sh @@ -147,6 +147,7 @@ info "Running onboard with an invalid policy mode to create resumable state..." FIRST_LOG="$(mktemp)" NEMOCLAW_NON_INTERACTIVE=1 \ + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 \ NEMOCLAW_SANDBOX_NAME="$SANDBOX_NAME" \ NEMOCLAW_RECREATE_SANDBOX=1 \ NEMOCLAW_POLICY_MODE=invalid \ @@ -209,6 +210,7 @@ info "Running onboard --resume with NVIDIA_API_KEY removed from env..." RESUME_LOG="$(mktemp)" env -u NVIDIA_API_KEY \ NEMOCLAW_NON_INTERACTIVE=1 \ + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 \ NEMOCLAW_SANDBOX_NAME="$SANDBOX_NAME" \ NEMOCLAW_POLICY_MODE=skip \ node "$REPO/bin/nemoclaw.js" onboard --resume --non-interactive >"$RESUME_LOG" 2>&1 diff --git a/test/e2e/test-spark-install.sh b/test/e2e/test-spark-install.sh index bc5f91339..9d7e2d2a3 100755 --- a/test/e2e/test-spark-install.sh +++ b/test/e2e/test-spark-install.sh @@ -9,17 +9,18 @@ # - Linux (DGX Spark or similar); other OS exits immediately (fail) # - Docker running # - sudo (for scripts/setup-spark.sh) unless NEMOCLAW_E2E_SPARK_SKIP_SETUP=1 -# - Same env your non-interactive install needs (e.g. NEMOCLAW_NON_INTERACTIVE=1, API keys, …) +# - Same env your non-interactive install needs (e.g. NEMOCLAW_NON_INTERACTIVE=1, NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1, API keys, …) # # Environment: -# NEMOCLAW_NON_INTERACTIVE=1 — required (matches full-e2e install phase) -# NEMOCLAW_E2E_SPARK_SKIP_SETUP=1 — skip sudo setup-spark (host already configured) -# NEMOCLAW_E2E_PUBLIC_INSTALL=1 — use curl|bash instead of repo install.sh -# NEMOCLAW_INSTALL_SCRIPT_URL — URL when using public install (default: nemoclaw.sh) -# INSTALL_LOG — log file (default: /tmp/nemoclaw-e2e-spark-install.log) +# NEMOCLAW_NON_INTERACTIVE=1 — required (matches full-e2e install phase) +# NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 — required for non-interactive install/onboard +# NEMOCLAW_E2E_SPARK_SKIP_SETUP=1 — skip sudo setup-spark (host already configured) +# NEMOCLAW_E2E_PUBLIC_INSTALL=1 — use curl|bash instead of repo install.sh +# NEMOCLAW_INSTALL_SCRIPT_URL — URL when using public install (default: nemoclaw.sh) +# INSTALL_LOG — log file (default: /tmp/nemoclaw-e2e-spark-install.log) # # Usage: -# NEMOCLAW_NON_INTERACTIVE=1 bash test/e2e/test-spark-install.sh +# NEMOCLAW_NON_INTERACTIVE=1 NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 bash test/e2e/test-spark-install.sh # # See: spark-install.md @@ -87,6 +88,13 @@ else exit 1 fi +if [ "${NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE:-}" = "1" ]; then + pass "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1" +else + fail "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 is required for non-interactive install" + exit 1 +fi + section "Phase 2: Spark Docker setup (sudo)" cd "$REPO" || { fail "cd to repo: $REPO" @@ -111,10 +119,10 @@ info "Log: $INSTALL_LOG" if [ "${NEMOCLAW_E2E_PUBLIC_INSTALL:-0}" = "1" ]; then url="${NEMOCLAW_INSTALL_SCRIPT_URL:-https://www.nvidia.com/nemoclaw.sh}" info "Running: curl -fsSL ... | bash (url=$url)" - curl -fsSL "$url" | bash >"$INSTALL_LOG" 2>&1 & + curl -fsSL "$url" | NEMOCLAW_NON_INTERACTIVE=1 NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 bash >"$INSTALL_LOG" 2>&1 & else info "Running: bash install.sh --non-interactive" - bash install.sh --non-interactive >"$INSTALL_LOG" 2>&1 & + NEMOCLAW_NON_INTERACTIVE=1 NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 bash install.sh --non-interactive >"$INSTALL_LOG" 2>&1 & fi install_pid=$! tail -f "$INSTALL_LOG" --pid=$install_pid 2>/dev/null & diff --git a/test/e2e/test-telegram-injection.sh b/test/e2e/test-telegram-injection.sh index 64ae41efb..47e8a98a2 100755 --- a/test/e2e/test-telegram-injection.sh +++ b/test/e2e/test-telegram-injection.sh @@ -32,7 +32,7 @@ # NVIDIA_API_KEY — required # # Usage: -# NEMOCLAW_NON_INTERACTIVE=1 NVIDIA_API_KEY=nvapi-... bash test/e2e/test-telegram-injection.sh +# NEMOCLAW_NON_INTERACTIVE=1 NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 NVIDIA_API_KEY=nvapi-... bash test/e2e/test-telegram-injection.sh # # See: https://github.com/NVIDIA/NemoClaw/issues/118 # https://github.com/NVIDIA/NemoClaw/pull/119 diff --git a/test/install-preflight.test.js b/test/install-preflight.test.js index ea52069ae..6050cd50d 100644 --- a/test/install-preflight.test.js +++ b/test/install-preflight.test.js @@ -27,12 +27,11 @@ function writeNodeStub(fakeBin) { path.join(fakeBin, "node"), `#!/usr/bin/env bash if [ "$1" = "--version" ] || [ "$1" = "-v" ]; then echo "v22.16.0"; exit 0; fi +if [ -n "\${1:-}" ] && [ -f "$1" ]; then + exec ${JSON.stringify(process.execPath)} "$@" +fi if [ "$1" = "-e" ]; then - if [[ "$2" == *"dependencies.openclaw"* ]]; then - echo "2026.3.11" - exit 0 - fi - exit 0 + exec ${JSON.stringify(process.execPath)} "$@" fi exit 99`, ); @@ -128,6 +127,9 @@ if [ "$1" = "--version" ]; then echo "v22.16.0" exit 0 fi +if [ -n "\${1:-}" ] && [ -f "$1" ]; then + exec ${JSON.stringify(process.execPath)} "$@" +fi if [ "$1" = "-e" ]; then exit 1 fi @@ -200,6 +202,7 @@ exit 98 HOME: tmp, PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", NPM_PREFIX: prefix, GIT_LOG_PATH: gitLog, }, @@ -282,6 +285,7 @@ exit 98 HOME: tmp, PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", NPM_PREFIX: prefix, }, }); @@ -415,6 +419,7 @@ fi`, HOME: tmp, PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", NPM_PREFIX: prefix, NPM_LOG_PATH: npmLog, }, @@ -483,6 +488,7 @@ fi`, HOME: tmp, PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", NPM_PREFIX: prefix, NEMOCLAW_ONBOARD_LOG: onboardLog, }, @@ -492,7 +498,108 @@ fi`, expect(`${result.stdout}${result.stderr}`).toMatch( /Found an interrupted onboarding session — resuming it\./, ); - expect(fs.readFileSync(onboardLog, "utf-8")).toMatch(/^onboard --resume --non-interactive$/m); + expect(fs.readFileSync(onboardLog, "utf-8")).toMatch( + /^onboard --resume --non-interactive --yes-i-accept-third-party-software$/m, + ); + }); + + it("requires explicit terms acceptance in non-interactive install mode", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-terms-required-")); + const fakeBin = path.join(tmp, "bin"); + const prefix = path.join(tmp, "prefix"); + const onboardLog = path.join(tmp, "onboard.log"); + fs.mkdirSync(fakeBin); + fs.mkdirSync(path.join(prefix, "bin"), { recursive: true }); + + writeNodeStub(fakeBin); + writeNpmStub( + fakeBin, + `if [ "$1" = "pack" ]; then + tmpdir="$4" + mkdir -p "$tmpdir/package" + tar -czf "$tmpdir/openclaw-2026.3.11.tgz" -C "$tmpdir" package + exit 0 +fi +if [ "$1" = "install" ]; then exit 0; fi +if [ "$1" = "run" ] && { [ "$2" = "build" ] || [ "$2" = "build:cli" ] || [ "$2" = "--if-present" ]; }; then exit 0; fi +if [ "$1" = "link" ]; then + cat > "$NPM_PREFIX/bin/nemoclaw" <<'EOS' +#!/usr/bin/env bash +printf '%s\\n' "$*" >> "$NEMOCLAW_ONBOARD_LOG" +exit 0 +EOS + chmod +x "$NPM_PREFIX/bin/nemoclaw" + exit 0 +fi`, + ); + + const result = spawnSync("bash", [INSTALLER, "--non-interactive"], { + cwd: path.join(import.meta.dirname, ".."), + encoding: "utf-8", + env: { + ...process.env, + HOME: tmp, + PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, + NPM_PREFIX: prefix, + NEMOCLAW_ONBOARD_LOG: onboardLog, + }, + }); + + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toMatch(/--yes-i-accept-third-party-software/); + expect(fs.existsSync(onboardLog)).toBe(false); + }); + + it("passes the acceptance flag through to non-interactive onboard", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-terms-accept-")); + const fakeBin = path.join(tmp, "bin"); + const prefix = path.join(tmp, "prefix"); + const onboardLog = path.join(tmp, "onboard.log"); + fs.mkdirSync(fakeBin); + fs.mkdirSync(path.join(prefix, "bin"), { recursive: true }); + + writeNodeStub(fakeBin); + writeNpmStub( + fakeBin, + `if [ "$1" = "pack" ]; then + tmpdir="$4" + mkdir -p "$tmpdir/package" + tar -czf "$tmpdir/openclaw-2026.3.11.tgz" -C "$tmpdir" package + exit 0 +fi +if [ "$1" = "install" ]; then exit 0; fi +if [ "$1" = "run" ] && { [ "$2" = "build" ] || [ "$2" = "build:cli" ] || [ "$2" = "--if-present" ]; }; then exit 0; fi +if [ "$1" = "link" ]; then + cat > "$NPM_PREFIX/bin/nemoclaw" <<'EOS' +#!/usr/bin/env bash +printf '%s\\n' "$*" >> "$NEMOCLAW_ONBOARD_LOG" +exit 0 +EOS + chmod +x "$NPM_PREFIX/bin/nemoclaw" + exit 0 +fi`, + ); + + const result = spawnSync( + "bash", + [INSTALLER, "--non-interactive", "--yes-i-accept-third-party-software"], + { + cwd: path.join(import.meta.dirname, ".."), + encoding: "utf-8", + env: { + ...process.env, + HOME: tmp, + PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, + NPM_PREFIX: prefix, + NEMOCLAW_ONBOARD_LOG: onboardLog, + }, + }, + ); + + expect(result.status).toBe(0); + expect(fs.readFileSync(onboardLog, "utf-8")).toMatch( + /^onboard --non-interactive --yes-i-accept-third-party-software$/m, + ); }); it("spin() non-TTY: dumps wrapped-command output and exits non-zero on failure", () => { @@ -536,6 +643,7 @@ fi`, HOME: tmp, PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", NPM_PREFIX: prefix, }, }); @@ -559,6 +667,9 @@ if [ "$1" = "-v" ] || [ "$1" = "--version" ]; then echo "v22.16.0" exit 0 fi +if [ -n "\${1:-}" ] && [ -f "$1" ]; then + exec ${JSON.stringify(process.execPath)} "$@" +fi if [ "$1" = "-e" ]; then exit 1 fi @@ -650,6 +761,7 @@ exit 0 HOME: tmp, PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", NPM_PREFIX: prefix, }, }); @@ -678,6 +790,9 @@ if [ "$1" = "-v" ] || [ "$1" = "--version" ]; then echo "v22.16.0" exit 0 fi +if [ -n "\${1:-}" ] && [ -f "$1" ]; then + exec ${JSON.stringify(process.execPath)} "$@" +fi if [ "$1" = "-e" ]; then exit 1 fi @@ -765,6 +880,7 @@ exit 0 HOME: tmp, PATH: `${fakeBin}:${prefixBin}:${TEST_SYSTEM_PATH}`, NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", NPM_PREFIX: prefix, NVM_DIR: nvmDir, }, @@ -820,6 +936,7 @@ fi`, HOME: tmp, PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", NPM_PREFIX: prefix, NVM_DIR: nvmDir, }, @@ -956,6 +1073,7 @@ exit 0`, HOME: tmp, PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", NPM_PREFIX: prefix, GIT_LOG_PATH: gitLog, }, @@ -1027,6 +1145,7 @@ fi`, HOME: tmp, PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", NPM_PREFIX: prefix, GIT_LOG_PATH: gitLog, }, @@ -1376,6 +1495,9 @@ describe("curl-pipe installer release-tag resolution", () => { path.join(fakeBin, "node"), `#!/usr/bin/env bash if [ "$1" = "-v" ] || [ "$1" = "--version" ]; then echo "v22.16.0"; exit 0; fi +if [ -n "\${1:-}" ] && [ -f "$1" ]; then + exec ${JSON.stringify(process.execPath)} "$@" +fi if [ "$1" = "-e" ]; then exit 1; fi exit 99`, ); @@ -1446,6 +1568,7 @@ exit 0`, HOME: tmp, PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", NPM_PREFIX: prefix, GIT_LOG_PATH: gitLog, }, @@ -1487,6 +1610,7 @@ exit 0`, HOME: tmp, PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", NPM_PREFIX: prefix, GIT_LOG_PATH: gitLog, NEMOCLAW_INSTALL_TAG: "v0.2.0", diff --git a/test/usage-notice.test.js b/test/usage-notice.test.js new file mode 100644 index 000000000..4b5659b9f --- /dev/null +++ b/test/usage-notice.test.js @@ -0,0 +1,154 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; + +const repoRoot = path.join(import.meta.dirname, ".."); +const noticePath = path.join(repoRoot, "bin", "lib", "usage-notice.js"); +const { + NOTICE_ACCEPT_FLAG, + ensureUsageNoticeConsent, + formatTerminalHyperlink, + getUsageNoticeStateFile, + hasAcceptedUsageNotice, + loadUsageNoticeConfig, + printUsageNotice, +} = require(noticePath); + +describe("usage notice", () => { + const originalIsTTY = process.stdin.isTTY; + const originalHome = process.env.HOME; + let testHome = null; + + beforeEach(() => { + testHome = fs.mkdtempSync(path.join(import.meta.dirname, "usage-notice-home-")); + process.env.HOME = testHome; + try { + fs.rmSync(getUsageNoticeStateFile(), { force: true }); + } catch { + // ignore cleanup errors + } + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: true, + }); + }); + + afterEach(() => { + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: originalIsTTY, + }); + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + if (testHome) { + fs.rmSync(testHome, { force: true, recursive: true }); + testHome = null; + } + }); + + it("requires the non-interactive acceptance flag", async () => { + const lines = []; + const ok = await ensureUsageNoticeConsent({ + nonInteractive: true, + acceptedByFlag: false, + writeLine: (line) => lines.push(line), + }); + + expect(ok).toBe(false); + expect(lines.join("\n")).toContain(NOTICE_ACCEPT_FLAG); + }); + + it("records acceptance in non-interactive mode when the flag is present", async () => { + const config = loadUsageNoticeConfig(); + const ok = await ensureUsageNoticeConsent({ + nonInteractive: true, + acceptedByFlag: true, + writeLine: () => {}, + }); + + expect(ok).toBe(true); + expect(hasAcceptedUsageNotice(config.version)).toBe(true); + }); + + it("cancels interactive onboarding unless the user types yes", async () => { + const lines = []; + const ok = await ensureUsageNoticeConsent({ + nonInteractive: false, + promptFn: async () => "no", + writeLine: (line) => lines.push(line), + }); + + expect(ok).toBe(false); + expect(lines.join("\n")).toContain("Installation cancelled"); + }); + + it("records interactive acceptance when the user types yes", async () => { + const config = loadUsageNoticeConfig(); + const ok = await ensureUsageNoticeConsent({ + nonInteractive: false, + promptFn: async () => "yes", + writeLine: () => {}, + }); + + expect(ok).toBe(true); + expect(hasAcceptedUsageNotice(config.version)).toBe(true); + }); + + it("fails interactive mode without a tty", async () => { + const lines = []; + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: false, + }); + + const ok = await ensureUsageNoticeConsent({ + nonInteractive: false, + promptFn: async () => "yes", + writeLine: (line) => lines.push(line), + }); + + expect(ok).toBe(false); + expect(lines.join("\n")).toContain("Interactive onboarding requires a TTY"); + }); + + it("renders url lines as terminal hyperlinks when tty output is available", () => { + const lines = []; + const originalStdoutIsTTY = process.stdout.isTTY; + const originalStderrIsTTY = process.stderr.isTTY; + try { + Object.defineProperty(process.stdout, "isTTY", { + configurable: true, + value: true, + }); + Object.defineProperty(process.stderr, "isTTY", { + configurable: true, + value: true, + }); + + printUsageNotice(loadUsageNoticeConfig(), (line) => lines.push(line)); + } finally { + Object.defineProperty(process.stdout, "isTTY", { + configurable: true, + value: originalStdoutIsTTY, + }); + Object.defineProperty(process.stderr, "isTTY", { + configurable: true, + value: originalStderrIsTTY, + }); + } + + expect(lines.join("\n")).toContain( + formatTerminalHyperlink( + "https://docs.openclaw.ai/gateway/security", + "https://docs.openclaw.ai/gateway/security", + ), + ); + expect(lines.join("\n")).toContain("https://docs.openclaw.ai/gateway/security"); + }); +});