diff --git a/src/lib/build-context.test.ts b/src/lib/build-context.test.ts index 41516f5c9e3..897c13e7305 100644 --- a/src/lib/build-context.test.ts +++ b/src/lib/build-context.test.ts @@ -203,6 +203,25 @@ describe("printSandboxCreateRecoveryHints", () => { expect(out).toContain("NEMOCLAW_SANDBOX_GPU=0"); expect(out).toContain("onboard --resume --no-gpu"); }); + + it("prints plugin-install network-policy guidance when the Docker build fails at the OpenClaw plugin install step", () => { + const output = [ + "npm error code ENOTFOUND", + "npm error network request to https://registry.npmjs.org/@openclaw%2Fbrave-plugin failed, reason: getaddrinfo ENOTFOUND registry.npmjs.org", + "Docker stream error: The command '/bin/bash -o pipefail -c set -eu;", + ' openclaw plugins install "npm:@openclaw/brave-plugin@2026.5.27" --pin;', + "fi' returned a non-zero code: 1", + ].join("\n"); + printSandboxCreateRecoveryHints(output); + + const out = stderr(); + expect(out).toContain("OpenClaw plugin-install step"); + expect(out).toContain("ClawHub"); + expect(out).toContain("npm registry"); + expect(out).toContain("network policy"); + expect(out).toContain("NEMOCLAW_WEB_SEARCH_ENABLED=0"); + expect(out).toContain("onboard --resume"); + }); }); describe("reconstructImageRefCreateCommand", () => { diff --git a/src/lib/build-context.ts b/src/lib/build-context.ts index 4e70a771522..8eaffffa872 100644 --- a/src/lib/build-context.ts +++ b/src/lib/build-context.ts @@ -218,6 +218,21 @@ export function printSandboxCreateRecoveryHints( console.error(` Recovery: ${CLI_NAME} onboard --resume --no-gpu`); return; } + if (failure.kind === "plugin_install_network_denied") { + console.error(" Hint: The sandbox Docker build failed at the OpenClaw plugin-install step."); + console.error( + " Could not reach ClawHub or the npm registry — your sandbox network policy", + ); + console.error( + " may be blocking outbound plugin-install access. Check whether an active", + ); + console.error(" preset allows egress to the npm registry and ClawHub, or disable the"); + console.error( + " feature that requires this plugin (e.g. NEMOCLAW_WEB_SEARCH_ENABLED=0).", + ); + console.error(` Recovery: ${CLI_NAME} onboard --resume`); + return; + } console.error(` Recovery: ${CLI_NAME} onboard --resume`); console.error(` Or: ${CLI_NAME} onboard`); } diff --git a/src/lib/validation-plugin-install.test.ts b/src/lib/validation-plugin-install.test.ts new file mode 100644 index 00000000000..3a2ee8c3621 --- /dev/null +++ b/src/lib/validation-plugin-install.test.ts @@ -0,0 +1,108 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { classifySandboxCreateFailure } from "./validation"; + +describe("classifySandboxCreateFailure plugin-install network arm", () => { + it("detects plugin install network denial from ENOTFOUND against the npm registry", () => { + const output = [ + "npm error code ENOTFOUND", + "npm error errno ENOTFOUND", + "npm error network request to https://registry.npmjs.org/@openclaw%2Fbrave-plugin failed, reason: getaddrinfo ENOTFOUND registry.npmjs.org", + "Docker stream error: The command '/bin/bash -o pipefail -c set -eu;", + ' openclaw plugins install "npm:@openclaw/brave-plugin@2026.5.27" --pin;', + " BRAVE_API_KEY=openshell:resolve:env:BRAVE_API_KEY openclaw doctor --fix --non-interactive;", + "fi' returned a non-zero code: 1", + ].join("\n"); + const result = classifySandboxCreateFailure(output); + expect(result.kind).toBe("plugin_install_network_denied"); + expect(result.uploadedToGateway).toBe(false); + }); + + it("detects plugin install network denial from ECONNREFUSED against ClawHub", () => { + const output = [ + "npm error code ECONNREFUSED", + "npm error network request to https://registry.clawhub.io/@openclaw%2Fdiagnostics-otel failed, reason: connect ECONNREFUSED 34.120.54.1:443", + "The command '...openclaw plugins install npm:@openclaw/diagnostics-otel@2026.5.27 --pin...' returned a non-zero code: 1", + ].join("\n"); + expect(classifySandboxCreateFailure(output).kind).toBe("plugin_install_network_denied"); + }); + + it("does NOT classify unrelated failures as plugin_install_network_denied", () => { + expect(classifySandboxCreateFailure("npm install failed with ENOENT").kind).toBe("unknown"); + expect(classifySandboxCreateFailure("openclaw doctor --fix failed").kind).toBe("unknown"); + }); + + it("does NOT classify as plugin_install_network_denied when plugin install fails for a non-network reason", () => { + const output = [ + "npm error code E404", + "npm error 404 Not Found - GET https://registry.npmjs.org/@openclaw%2Fmissing-plugin", + "npm error 404 '@openclaw/missing-plugin@0.0.0' is not in the npm registry", + "The command '/bin/bash -c openclaw plugins install npm:@openclaw/missing-plugin@0.0.0 --pin' returned a non-zero code: 1", + ].join("\n"); + expect(classifySandboxCreateFailure(output).kind).toBe("unknown"); + }); + + it("does NOT classify as plugin_install_network_denied when plugin install step succeeded and a later step failed", () => { + const output = [ + "Step 3/10 : RUN openclaw plugins install npm:@openclaw/brave-plugin@2026.5.27 --pin", + " ---> Running in abc123", + " ---> def456", + "Step 4/10 : RUN fail-step", + " ---> Running in xyz789", + "The command '/bin/sh -c fail-step' returned a non-zero code: 1", + ].join("\n"); + expect(classifySandboxCreateFailure(output).kind).toBe("unknown"); + }); + + it("does NOT classify as plugin_install_network_denied when plugin install succeeded but a later command in the same RUN block failed with a network error", () => { + // The same RUN block runs `openclaw plugins install` followed by + // `openclaw doctor --fix`. If the install succeeds but doctor's network + // call fails, the block fails but npm never emits an "npm error" line, + // so the classifier must not fire the plugin-install hint. + const output = [ + "The command '/bin/bash -o pipefail -c set -eu;", + ' openclaw plugins install "npm:@openclaw/brave-plugin@2026.5.27" --pin;', + " BRAVE_API_KEY=openshell:resolve:env:BRAVE_API_KEY openclaw doctor --fix --non-interactive;", + "fi' returned a non-zero code: 1", + "error: getaddrinfo ENOTFOUND api.openclaw.ai", + ].join("\n"); + expect(classifySandboxCreateFailure(output).kind).toBe("unknown"); + }); + + it("does NOT classify as plugin_install_network_denied when an npm script after the plugin install in the same RUN block emits a network npm error", () => { + // If `openclaw plugins install` succeeds but a later npm-based command in + // the same RUN block fails, its stderr appears before Docker's final failed- + // command summary. Correlating the error URL to the requested plugin keeps + // the unrelated package failure on the generic recovery path. + const output = [ + "npm error code ENOTFOUND", + "npm error network request to https://registry.npmjs.org/@openclaw%2Ftools failed, reason: getaddrinfo ENOTFOUND registry.npmjs.org", + "The command '/bin/bash -o pipefail -c set -eu;", + ' openclaw plugins install "npm:@openclaw/brave-plugin@2026.5.27" --pin;', + " npm run doctor-fix;", + "fi' returned a non-zero code: 1", + ].join("\n"); + expect(classifySandboxCreateFailure(output).kind).toBe("unknown"); + }); + + it("does NOT classify package-agnostic npm network output as a plugin install denial", () => { + const output = [ + "npm error code ENOTFOUND", + "npm error network request to https://registry.npmjs.org failed, reason: getaddrinfo ENOTFOUND registry.npmjs.org", + "The command '/bin/bash -c openclaw plugins install npm:@openclaw/brave-plugin@2026.5.27 --pin' returned a non-zero code: 1", + ].join("\n"); + expect(classifySandboxCreateFailure(output).kind).toBe("unknown"); + }); + + it("does NOT classify a non-plugin command that names the same scoped package", () => { + const output = [ + "npm error code ENOTFOUND", + "npm error network request to https://registry.npmjs.org/@openclaw%2Ftools failed, reason: getaddrinfo ENOTFOUND registry.npmjs.org", + "The command '/bin/sh -c npm install npm:@openclaw/tools' returned a non-zero code: 1", + ].join("\n"); + expect(classifySandboxCreateFailure(output).kind).toBe("unknown"); + }); +}); diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 03ab1b3b90a..ab071a1c2bb 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -20,6 +20,7 @@ export interface SandboxCreateFailure { | "sandbox_create_incomplete" | "tls_cert_mismatch" | "gpu_cdi_injection_failed" + | "plugin_install_network_denied" | "unknown"; uploadedToGateway: boolean; } @@ -134,6 +135,48 @@ export function classifySandboxCreateFailure(output = ""): SandboxCreateFailure ) { return { kind: "gpu_cdi_injection_failed", uploadedToGateway }; } + // Require BOTH the failed Docker command block containing the plugin-install + // step AND npm-prefixed network evidence for the same plugin package. Docker + // prints subprocess stderr before its final failed-command summary, so a + // prefix-only search can misattribute a later npm command in the same RUN + // block. Package correlation keeps that failure on the generic recovery path. + // OpenShell exposes only combined Docker text here, so this classifier is the + // source boundary until callers can consume a structured plugin-install + // failure with package identity; remove the text classifier at that point. + // In JavaScript, [^'] matches every character except a single quote, + // including newlines (unlike `.` without the dotAll flag), so multi-line + // command text is handled correctly. See #4127 / follow-up from #4125. + const pluginInstallErrorMatch = + /The command '[^']*openclaw plugins install[^']*'\s*returned a non-zero code/i.exec(text); + if (pluginInstallErrorMatch) { + const segment = text.slice( + 0, + pluginInstallErrorMatch.index + pluginInstallErrorMatch[0].length, + ); + const pluginPackages = [ + ...pluginInstallErrorMatch[0].matchAll(/(?:npm:)?(@openclaw\/[a-z0-9._-]+)/gi), + ].map((match) => match[1].toLowerCase()); + const npmErrorText = segment + .split(/\r?\n/) + .filter((line) => /^\s*npm error\b/i.test(line)) + .join("\n") + .toLowerCase(); + // npm output may print the scoped-package slash literally or percent- + // encoded. Normalize comparisons to lowercase so %2F and %2f both match. + const hasMatchingPluginPackage = pluginPackages.some((packageName) => + [packageName, packageName.replaceAll("/", "%2f"), encodeURIComponent(packageName)].some( + (candidate) => npmErrorText.includes(candidate.toLowerCase()), + ), + ); + if ( + hasMatchingPluginPackage && + /npm error.*(?:ENOTFOUND|EAI_AGAIN|ECONNREFUSED|ETIMEDOUT|ESOCKETTIMEDOUT|network request.*failed|getaddrinfo|fetch failed|socket hang up|network timeout)/i.test( + npmErrorText, + ) + ) { + return { kind: "plugin_install_network_denied", uploadedToGateway }; + } + } if (/Created sandbox:/i.test(text)) { return { kind: "sandbox_create_incomplete", uploadedToGateway: true }; }