Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/lib/build-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
15 changes: 15 additions & 0 deletions src/lib/build-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
}
108 changes: 108 additions & 0 deletions src/lib/validation-plugin-install.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
43 changes: 43 additions & 0 deletions src/lib/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface SandboxCreateFailure {
| "sandbox_create_incomplete"
| "tls_cert_mismatch"
| "gpu_cdi_injection_failed"
| "plugin_install_network_denied"
| "unknown";
uploadedToGateway: boolean;
}
Expand Down Expand Up @@ -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 };
}
Expand Down
Loading