From e10ed6cb909664b02702332e6259f78c4182e6a1 Mon Sep 17 00:00:00 2001 From: Dongni Yang Date: Wed, 24 Jun 2026 09:39:44 +0800 Subject: [PATCH 1/8] fix(onboard): surface a targeted hint when the Docker build fails at the openclaw plugins install step When `openshell sandbox create` fails because the Dockerfile's `openclaw plugins install` RUN step exits non-zero, the build output contains the distinctive strings "openclaw plugins install" or "npm:@openclaw/", but `classifySandboxCreateFailure` had no branch for this pattern and fell through to `kind="unknown"`. The resulting hint was the generic "nemoclaw onboard --resume" line with no indication of the likely cause. Add a `"plugin_install_network_denied"` kind to `SandboxCreateFailure` and a classifier that matches those strings in the openshell create output. `printSandboxCreateRecoveryHints` now emits a targeted message noting that network policy may be blocking outbound access to ClawHub or the npm registry, and suggests disabling the feature (e.g. `NEMOCLAW_WEB_SEARCH_ENABLED=0`) as an alternative if a preset isn't available. Fixes #4127 Signed-off-by: Dongni Yang --- src/lib/build-context.ts | 15 +++++++++++++++ src/lib/validation.test.ts | 23 +++++++++++++++++++++++ src/lib/validation.ts | 8 ++++++++ 3 files changed, 46 insertions(+) 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.test.ts b/src/lib/validation.test.ts index f4c671c84cd..be323434d27 100644 --- a/src/lib/validation.test.ts +++ b/src/lib/validation.test.ts @@ -289,6 +289,29 @@ describe("classifySandboxCreateFailure", () => { ).toBe("unknown"); expect(classifySandboxCreateFailure("HTTP 404: model not found").kind).toBe("unknown"); }); + + it("detects plugin install failure from the npm:@openclaw/ package spec in the failed command", () => { + const output = [ + "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 failure from the openclaw plugins install command text", () => { + const output = + "The command '...openclaw plugins install npm:@openclaw/diagnostics-otel@2026.5.27 --pin...' returned a non-zero code: 1"; + 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"); + }); }); describe("planSandboxCreateRecovery", () => { diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 03ab1b3b90a..ae1330672cb 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,13 @@ export function classifySandboxCreateFailure(output = ""): SandboxCreateFailure ) { return { kind: "gpu_cdi_injection_failed", uploadedToGateway }; } + // The Docker build RUN step that runs `openclaw plugins install` embeds the + // command text in its failure message. Match it so the hint can surface the + // likely cause (network policy blocking npm/ClawHub egress) instead of the + // generic recovery line. See #4127 / follow-up from #4125. + if (/openclaw plugins install|npm:@openclaw\//i.test(text)) { + return { kind: "plugin_install_network_denied", uploadedToGateway }; + } if (/Created sandbox:/i.test(text)) { return { kind: "sandbox_create_incomplete", uploadedToGateway: true }; } From 911d72be56611b80b09517b4e51a187c74699c8b Mon Sep 17 00:00:00 2001 From: Dongni Yang Date: Wed, 24 Jun 2026 09:52:19 +0800 Subject: [PATCH 2/8] fix(onboard): anchor plugin-install classifier to Docker error block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous regex matched `openclaw plugins install|npm:@openclaw/` anywhere in the captured output, which would mis-classify a build where that step succeeded and a later step failed — the Docker step header (`Step N: RUN openclaw plugins install ...`) would match even though the failure came from an unrelated subsequent RUN step. Tighten to the Docker error block format: The command '......' returned a non-zero code `[^']*` is used (not `[^\n]*`) because the embedded shell command often spans multiple lines (chained commands joined with semicolons), and JS character classes match newlines. Add a regression test that verifies the step-header false-positive is rejected. Refs #4127 Signed-off-by: Dongni Yang --- src/lib/validation.test.ts | 12 ++++++++++++ src/lib/validation.ts | 15 +++++++++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/lib/validation.test.ts b/src/lib/validation.test.ts index be323434d27..bff58bc9407 100644 --- a/src/lib/validation.test.ts +++ b/src/lib/validation.test.ts @@ -312,6 +312,18 @@ describe("classifySandboxCreateFailure", () => { 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 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"); + }); }); describe("planSandboxCreateRecovery", () => { diff --git a/src/lib/validation.ts b/src/lib/validation.ts index ae1330672cb..f8ca195221f 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -136,10 +136,17 @@ export function classifySandboxCreateFailure(output = ""): SandboxCreateFailure return { kind: "gpu_cdi_injection_failed", uploadedToGateway }; } // The Docker build RUN step that runs `openclaw plugins install` embeds the - // command text in its failure message. Match it so the hint can surface the - // likely cause (network policy blocking npm/ClawHub egress) instead of the - // generic recovery line. See #4127 / follow-up from #4125. - if (/openclaw plugins install|npm:@openclaw\//i.test(text)) { + // command text in its failure message. Anchor to the Docker error block + // (The command '...' returned a non-zero code) so a step-header occurrence + // of the command — when the plugin step itself succeeded and a later step + // failed — does not fire the wrong hint. [^']* matches newlines in JS + // character classes, so multi-line command text is handled correctly. + // See #4127 / follow-up from #4125. + if ( + /The command '[^']*(?:openclaw plugins install|npm:@openclaw\/)[^']*'\s*returned a non-zero code/i.test( + text, + ) + ) { return { kind: "plugin_install_network_denied", uploadedToGateway }; } if (/Created sandbox:/i.test(text)) { From 563708ca4525e6dc54dbfd64b5c6f56c781473c7 Mon Sep 17 00:00:00 2001 From: Dongni Yang Date: Fri, 26 Jun 2026 11:51:38 +0800 Subject: [PATCH 3/8] fix(onboard): require network-error evidence before classifying plugin-install failure as network-denied MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous classifier fired on any failed `openclaw plugins install` Docker command, including package-not-found (HTTP 404 from the registry), version conflicts, and auth errors — all of which would receive a misleading "network policy blocking egress" hint. Narrow the match to require both: 1. The Docker error block anchored to the failed plugin-install step 2. A network/egress reachability error (ENOTFOUND, EAI_AGAIN, ECONNREFUSED, ETIMEDOUT, fetch failed, etc.) in the captured output This ensures the hint "your sandbox network policy may be blocking outbound plugin-install access" is only shown when the underlying failure is actually a network reachability problem. Test changes: - Updated positive tests to include ENOTFOUND / ECONNREFUSED output matching real npm network error messages (registry.npmjs.org and ClawHub paths respectively) - Added negative test: same Docker failed-command block but E404 package-not-found → classifies as "unknown" - Added direct build-context.test.ts coverage for the new hint branch, asserting the key user-visible strings (plugin-install step, ClawHub, npm registry, network policy, NEMOCLAW_WEB_SEARCH_ENABLED=0, onboard --resume) Refs #4127 Signed-off-by: Dongni Yang --- src/lib/build-context.test.ts | 19 +++++++++++++++++++ src/lib/validation.test.ts | 24 ++++++++++++++++++++---- src/lib/validation.ts | 16 +++++++++------- 3 files changed, 48 insertions(+), 11 deletions(-) diff --git a/src/lib/build-context.test.ts b/src/lib/build-context.test.ts index add0bb94bca..c47b2c26efb 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/validation.test.ts b/src/lib/validation.test.ts index bff58bc9407..1c22892038f 100644 --- a/src/lib/validation.test.ts +++ b/src/lib/validation.test.ts @@ -290,8 +290,11 @@ describe("classifySandboxCreateFailure", () => { expect(classifySandboxCreateFailure("HTTP 404: model not found").kind).toBe("unknown"); }); - it("detects plugin install failure from the npm:@openclaw/ package spec in the failed command", () => { + 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;", @@ -302,9 +305,12 @@ describe("classifySandboxCreateFailure", () => { expect(result.uploadedToGateway).toBe(false); }); - it("detects plugin install failure from the openclaw plugins install command text", () => { - const output = - "The command '...openclaw plugins install npm:@openclaw/diagnostics-otel@2026.5.27 --pin...' returned a non-zero code: 1"; + 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"); }); @@ -313,6 +319,16 @@ describe("classifySandboxCreateFailure", () => { 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", diff --git a/src/lib/validation.ts b/src/lib/validation.ts index f8ca195221f..efecdcf1d6b 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -135,16 +135,18 @@ export function classifySandboxCreateFailure(output = ""): SandboxCreateFailure ) { return { kind: "gpu_cdi_injection_failed", uploadedToGateway }; } - // The Docker build RUN step that runs `openclaw plugins install` embeds the - // command text in its failure message. Anchor to the Docker error block - // (The command '...' returned a non-zero code) so a step-header occurrence - // of the command — when the plugin step itself succeeded and a later step - // failed — does not fire the wrong hint. [^']* matches newlines in JS - // character classes, so multi-line command text is handled correctly. - // See #4127 / follow-up from #4125. + // Require BOTH the failed Docker command block containing the plugin-install + // step AND a network/egress reachability error so that non-network failures + // (package-not-found, version conflicts, auth errors) fall through to the + // generic recovery rather than showing a misleading network-policy hint. + // [^']* matches newlines in JS character classes, so multi-line command text + // is handled correctly. See #4127 / follow-up from #4125. if ( /The command '[^']*(?:openclaw plugins install|npm:@openclaw\/)[^']*'\s*returned a non-zero code/i.test( text, + ) && + /ENOTFOUND|EAI_AGAIN|ECONNREFUSED|ETIMEDOUT|ESOCKETTIMEDOUT|network request.*failed|getaddrinfo|fetch failed|socket hang up|network timeout/i.test( + text, ) ) { return { kind: "plugin_install_network_denied", uploadedToGateway }; From 81ae694512494cb4e291488a20f02f58bb1ef20e Mon Sep 17 00:00:00 2001 From: Dongni Yang Date: Fri, 26 Jun 2026 12:10:05 +0800 Subject: [PATCH 4/8] fix(onboard): anchor network evidence to npm error output to prevent false positives from later RUN block commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous network-error predicate matched any ENOTFOUND/ECONNREFUSED/etc. in the captured output, which could fire the plugin-install hint when the plugin install itself succeeded but a later command in the same RUN block (e.g. `openclaw doctor --fix`) failed with a network error. npm's error output consistently prefixes every line with "npm error", whereas commands run after a successful install produce different error formats. Requiring "npm error" + network pattern anchors the evidence to the npm installer specifically, ruling out later-command false positives. Add a regression test: the same RUN block runs plugin install (succeeds) then openclaw doctor (fails with ENOTFOUND but no "npm error" prefix) → correctly returns "unknown". Refs #4127 Signed-off-by: Dongni Yang --- src/lib/validation.test.ts | 15 +++++++++++++++ src/lib/validation.ts | 16 ++++++++++------ 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/lib/validation.test.ts b/src/lib/validation.test.ts index 1c22892038f..699d6851c67 100644 --- a/src/lib/validation.test.ts +++ b/src/lib/validation.test.ts @@ -340,6 +340,21 @@ describe("classifySandboxCreateFailure", () => { ].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"); + }); }); describe("planSandboxCreateRecovery", () => { diff --git a/src/lib/validation.ts b/src/lib/validation.ts index efecdcf1d6b..5347e90c1b6 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -136,16 +136,20 @@ 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 a network/egress reachability error so that non-network failures - // (package-not-found, version conflicts, auth errors) fall through to the - // generic recovery rather than showing a misleading network-policy hint. - // [^']* matches newlines in JS character classes, so multi-line command text - // is handled correctly. See #4127 / follow-up from #4125. + // step AND an npm-prefixed network/egress error so that non-network failures + // (package-not-found, version conflicts, auth errors) and failures in later + // commands within the same RUN block (e.g. openclaw doctor --fix) fall + // through to the generic recovery rather than showing a misleading + // network-policy hint. Requiring the "npm error" prefix anchors the evidence + // to npm's own output — commands run after a successful plugin install + // produce different error formats and will not match. [^']* matches newlines + // in JS character classes, so multi-line command text is handled correctly. + // See #4127 / follow-up from #4125. if ( /The command '[^']*(?:openclaw plugins install|npm:@openclaw\/)[^']*'\s*returned a non-zero code/i.test( text, ) && - /ENOTFOUND|EAI_AGAIN|ECONNREFUSED|ETIMEDOUT|ESOCKETTIMEDOUT|network request.*failed|getaddrinfo|fetch failed|socket hang up|network timeout/i.test( + /npm error.*(?:ENOTFOUND|EAI_AGAIN|ECONNREFUSED|ETIMEDOUT|ESOCKETTIMEDOUT|network request.*failed|getaddrinfo|fetch failed|socket hang up|network timeout)/i.test( text, ) ) { From 9d8df94960b2204e358b6a207092cbb503fdec4a Mon Sep 17 00:00:00 2001 From: Dongni Yang Date: Fri, 26 Jun 2026 14:54:16 +0800 Subject: [PATCH 5/8] fix(onboard): window npm-error search to plugin-install failure segment Bound the network-evidence regex to the text up to and including the failed-plugin-install Docker error block rather than scanning the entire output. This prevents an npm script that runs after a successful plugin install in the same RUN block from producing a false plugin_install_network_denied classification when that later script emits an npm-prefixed network error. Adds a regression test for this case (npm script post-install in the same RUN block emits npm error ENOTFOUND after the Docker boundary). Refs #4127 Signed-off-by: Dongni Yang --- src/lib/validation.test.ts | 16 ++++++++++++++++ src/lib/validation.ts | 39 +++++++++++++++++++++----------------- 2 files changed, 38 insertions(+), 17 deletions(-) diff --git a/src/lib/validation.test.ts b/src/lib/validation.test.ts index 699d6851c67..d91cd59d3dd 100644 --- a/src/lib/validation.test.ts +++ b/src/lib/validation.test.ts @@ -355,6 +355,22 @@ describe("classifySandboxCreateFailure", () => { ].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, the npm error lines appear AFTER the Docker + // failed-command boundary. The windowed search only looks at text up to and + // including the Docker error block, so post-boundary npm errors are excluded. + const output = [ + "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", + "npm error code ENOTFOUND", + "npm error network request to https://registry.npmjs.org/@openclaw%2Ftools failed, reason: getaddrinfo ENOTFOUND registry.npmjs.org", + ].join("\n"); + expect(classifySandboxCreateFailure(output).kind).toBe("unknown"); + }); }); describe("planSandboxCreateRecovery", () => { diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 5347e90c1b6..764c1061e5f 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -136,24 +136,29 @@ 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 an npm-prefixed network/egress error so that non-network failures - // (package-not-found, version conflicts, auth errors) and failures in later - // commands within the same RUN block (e.g. openclaw doctor --fix) fall - // through to the generic recovery rather than showing a misleading - // network-policy hint. Requiring the "npm error" prefix anchors the evidence - // to npm's own output — commands run after a successful plugin install - // produce different error formats and will not match. [^']* matches newlines - // in JS character classes, so multi-line command text is handled correctly. - // See #4127 / follow-up from #4125. - if ( - /The command '[^']*(?:openclaw plugins install|npm:@openclaw\/)[^']*'\s*returned a non-zero code/i.test( - text, - ) && - /npm error.*(?:ENOTFOUND|EAI_AGAIN|ECONNREFUSED|ETIMEDOUT|ESOCKETTIMEDOUT|network request.*failed|getaddrinfo|fetch failed|socket hang up|network timeout)/i.test( + // step AND an npm-prefixed network/egress error within the text leading up to + // (and including) that block. Searching only the prefix of the output up to + // the Docker error boundary prevents a network failure in an unrelated later + // RUN block — or a different npm script in the same RUN block that runs after + // the plugin install succeeds — from triggering the hint. [^']* matches + // newlines in JS character classes, so multi-line command text is handled + // correctly. See #4127 / follow-up from #4125. + const pluginInstallErrorMatch = + /The command '[^']*(?:openclaw plugins install|npm:@openclaw\/)[^']*'\s*returned a non-zero code/i.exec( text, - ) - ) { - return { kind: "plugin_install_network_denied", uploadedToGateway }; + ); + if (pluginInstallErrorMatch) { + const segment = text.slice( + 0, + pluginInstallErrorMatch.index + pluginInstallErrorMatch[0].length, + ); + if ( + /npm error.*(?:ENOTFOUND|EAI_AGAIN|ECONNREFUSED|ETIMEDOUT|ESOCKETTIMEDOUT|network request.*failed|getaddrinfo|fetch failed|socket hang up|network timeout)/i.test( + segment, + ) + ) { + return { kind: "plugin_install_network_denied", uploadedToGateway }; + } } if (/Created sandbox:/i.test(text)) { return { kind: "sandbox_create_incomplete", uploadedToGateway: true }; From 49db962721dd6f361e71e6bd5ef750ad05ef21d2 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Mon, 29 Jun 2026 21:17:31 -0700 Subject: [PATCH 6/8] fix(onboard): correlate plugin install network errors --- src/lib/validation.test.ts | 17 +++++++++++++---- src/lib/validation.ts | 32 ++++++++++++++++++++++++-------- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/src/lib/validation.test.ts b/src/lib/validation.test.ts index d91cd59d3dd..1abdb7b6a52 100644 --- a/src/lib/validation.test.ts +++ b/src/lib/validation.test.ts @@ -358,16 +358,25 @@ describe("classifySandboxCreateFailure", () => { 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, the npm error lines appear AFTER the Docker - // failed-command boundary. The windowed search only looks at text up to and - // including the Docker error block, so post-boundary npm errors are excluded. + // 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/@openclaw%2Ftools failed, reason: getaddrinfo ENOTFOUND registry.npmjs.org", + "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"); }); diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 764c1061e5f..0b81f51f666 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -136,13 +136,15 @@ 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 an npm-prefixed network/egress error within the text leading up to - // (and including) that block. Searching only the prefix of the output up to - // the Docker error boundary prevents a network failure in an unrelated later - // RUN block — or a different npm script in the same RUN block that runs after - // the plugin install succeeds — from triggering the hint. [^']* matches - // newlines in JS character classes, so multi-line command text is handled - // correctly. See #4127 / follow-up from #4125. + // 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. + // [^']* matches newlines in JS character classes, so multi-line command text + // is handled correctly. See #4127 / follow-up from #4125. const pluginInstallErrorMatch = /The command '[^']*(?:openclaw plugins install|npm:@openclaw\/)[^']*'\s*returned a non-zero code/i.exec( text, @@ -152,9 +154,23 @@ export function classifySandboxCreateFailure(output = ""): SandboxCreateFailure 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(); + const hasMatchingPluginPackage = pluginPackages.some((packageName) => + [packageName, packageName.replace("/", "%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( - segment, + npmErrorText, ) ) { return { kind: "plugin_install_network_denied", uploadedToGateway }; From 4d9b8f653f62bea0bba9c8ffc2688c9016ad0d67 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Mon, 29 Jun 2026 21:33:52 -0700 Subject: [PATCH 7/8] test(onboard): isolate plugin failure classification Signed-off-by: Carlos Villela --- src/lib/validation-plugin-install.test.ts | 99 +++++++++++++++++++++++ src/lib/validation.test.ts | 91 --------------------- src/lib/validation.ts | 9 ++- 3 files changed, 105 insertions(+), 94 deletions(-) create mode 100644 src/lib/validation-plugin-install.test.ts diff --git a/src/lib/validation-plugin-install.test.ts b/src/lib/validation-plugin-install.test.ts new file mode 100644 index 00000000000..b89e0ab0a36 --- /dev/null +++ b/src/lib/validation-plugin-install.test.ts @@ -0,0 +1,99 @@ +// 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 "../../dist/lib/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"); + }); +}); diff --git a/src/lib/validation.test.ts b/src/lib/validation.test.ts index 1abdb7b6a52..f4c671c84cd 100644 --- a/src/lib/validation.test.ts +++ b/src/lib/validation.test.ts @@ -289,97 +289,6 @@ describe("classifySandboxCreateFailure", () => { ).toBe("unknown"); expect(classifySandboxCreateFailure("HTTP 404: model not found").kind).toBe("unknown"); }); - - 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"); - }); }); describe("planSandboxCreateRecovery", () => { diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 0b81f51f666..72775c41ba1 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -143,8 +143,9 @@ export function classifySandboxCreateFailure(output = ""): SandboxCreateFailure // 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. - // [^']* matches newlines in JS character classes, so multi-line command text - // is handled correctly. See #4127 / follow-up from #4125. + // 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|npm:@openclaw\/)[^']*'\s*returned a non-zero code/i.exec( text, @@ -162,8 +163,10 @@ export function classifySandboxCreateFailure(output = ""): SandboxCreateFailure .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.replace("/", "%2f"), encodeURIComponent(packageName)].some( + [packageName, packageName.replaceAll("/", "%2f"), encodeURIComponent(packageName)].some( (candidate) => npmErrorText.includes(candidate.toLowerCase()), ), ); From 8d4ee7e3715fdd7851aedc80dd2551fbb1c049e7 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Mon, 29 Jun 2026 22:08:37 -0700 Subject: [PATCH 8/8] fix(onboard): require plugin installer command Signed-off-by: Carlos Villela --- src/lib/validation-plugin-install.test.ts | 9 +++++++++ src/lib/validation.ts | 4 +--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/lib/validation-plugin-install.test.ts b/src/lib/validation-plugin-install.test.ts index a35e5a31b63..3a2ee8c3621 100644 --- a/src/lib/validation-plugin-install.test.ts +++ b/src/lib/validation-plugin-install.test.ts @@ -96,4 +96,13 @@ describe("classifySandboxCreateFailure plugin-install network arm", () => { ].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 72775c41ba1..ab071a1c2bb 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -147,9 +147,7 @@ export function classifySandboxCreateFailure(output = ""): SandboxCreateFailure // 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|npm:@openclaw\/)[^']*'\s*returned a non-zero code/i.exec( - text, - ); + /The command '[^']*openclaw plugins install[^']*'\s*returned a non-zero code/i.exec(text); if (pluginInstallErrorMatch) { const segment = text.slice( 0,