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
46 changes: 45 additions & 1 deletion .github/workflows/regression-e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ on:
jobs:
description: >-
Comma-separated regression job names to run (empty = all).
Valid: dashboard-remote-bind-e2e,gateway-health-honest-e2e,gateway-drift-preflight-e2e,openshell-version-pin-e2e,onboard-inference-smoke-e2e,strict-tool-call-probe-e2e,model-router-provider-routed-inference-e2e,openclaw-plugin-runtime-exdev-e2e
Valid: dashboard-remote-bind-e2e,gateway-health-honest-e2e,docker-unreachable-gateway-start-e2e,gateway-drift-preflight-e2e,openshell-version-pin-e2e,onboard-inference-smoke-e2e,strict-tool-call-probe-e2e,model-router-provider-routed-inference-e2e,openclaw-plugin-runtime-exdev-e2e
required: false
type: string
default: ""
Expand All @@ -46,6 +46,7 @@ jobs:
outputs:
dashboard: ${{ steps.select.outputs.dashboard }}
gateway: ${{ steps.select.outputs.gateway }}
docker_unreachable_gateway_start: ${{ steps.select.outputs.docker_unreachable_gateway_start }}
gateway_drift_preflight: ${{ steps.select.outputs.gateway_drift_preflight }}
openshell_version_pin: ${{ steps.select.outputs.openshell_version_pin }}
onboard_inference_smoke: ${{ steps.select.outputs.onboard_inference_smoke }}
Expand Down Expand Up @@ -79,6 +80,12 @@ jobs:
echo "gateway=false" >> "$GITHUB_OUTPUT"
fi

if [ -z "$normalized" ] || includes_job "docker-unreachable-gateway-start-e2e"; then
echo "docker_unreachable_gateway_start=true" >> "$GITHUB_OUTPUT"
else
echo "docker_unreachable_gateway_start=false" >> "$GITHUB_OUTPUT"
fi

if [ -z "$normalized" ] || includes_job "gateway-drift-preflight-e2e"; then
echo "gateway_drift_preflight=true" >> "$GITHUB_OUTPUT"
else
Expand Down Expand Up @@ -180,6 +187,43 @@ jobs:
/tmp/nemoclaw-e2e-gateway-health-honest-process.log
if-no-files-found: ignore

# ── Docker-unreachable gateway-start E2E ───────────────────
# Coverage guard for #2347's fallback path. Preflight catches Docker
# daemon outages before step [2/8] on the normal path; this focused
# regression simulates Docker dying during `openshell gateway start` and
# verifies onboard exits before health polling with Docker recovery advice.
docker-unreachable-gateway-start-e2e:
needs: select_regression_jobs
if: >-
github.repository == 'NVIDIA/NemoClaw' &&
needs.select_regression_jobs.outputs.docker_unreachable_gateway_start == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: "22"
Comment on lines +203 to +209

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

What is the current recommended commit SHA to pin actions/setup-node v6?

💡 Result:

Current recommended commit SHA to pin actions/setup-node v6 (as of 2026-06-03) is: 48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e. Rationale (how this was determined): GitHub’s actions/setup-node repository shows the latest v6 release as v6.4.0 [1][2][3]. The full commit SHA for that tagged release is shown in the v6.3.0...v6.4.0 comparison page, which lists the v6.4.0 head commit as 48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e [4].

Citations:


Harden docker-unreachable-gateway-start-e2e’s checkout/setup-node to match the sibling job.

This job (lines 203-209) doesn’t set persist-credentials: false on actions/checkout or pin actions/setup-node@v6; mirror the hardened pattern used by strict-tool-call-probe-e2e for supply-chain hygiene.

🔒 Proposed hardening
       - name: Checkout
         uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+        with:
+          persist-credentials: false
 
       - name: Setup Node
-        uses: actions/setup-node@v6
+        uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
         with:
           node-version: "22"
🧰 Tools
🪛 zizmor (1.25.2)

[warning] 203-204: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)


[error] 207-207: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/regression-e2e.yaml around lines 203 - 209, In the
docker-unreachable-gateway-start-e2e job, update the "Checkout" step to include
persist-credentials: false and replace the loose actions/setup-node@v6 reference
in the "Setup Node" step with the exact pinned ref used by the sibling
strict-tool-call-probe-e2e job (i.e., pin actions/setup-node to the same
commit/tag used there) so both checkout and setup-node match the hardened
pattern.


- name: Run Docker-unreachable gateway-start E2E test
env:
NEMOCLAW_NON_INTERACTIVE: "1"
NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1"
run: bash test/e2e/test-docker-unreachable-gateway-start.sh

- name: Upload Docker-unreachable gateway-start logs on failure
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: docker-unreachable-gateway-start-logs
path: |
/tmp/nemoclaw-e2e-docker-unreachable-gateway-start.log
/tmp/nemoclaw-e2e-docker-unreachable-gateway-start-node.log
/tmp/nemoclaw-e2e-docker-unreachable-gateway-start-openshell.log
if-no-files-found: ignore

# ── OpenShell version-pin E2E ──────────────────────────────
# Coverage guard for #3474. If a host has sticky OpenShell 0.0.45 on PATH
Expand Down
42 changes: 20 additions & 22 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,8 @@ const { trackChildExit } =
require("./onboard/child-exit-tracker") as typeof import("./onboard/child-exit-tracker");
const { reportDockerDriverGatewayStartFailure } =
require("./onboard/docker-driver-gateway-failure") as typeof import("./onboard/docker-driver-gateway-failure");
const { printDockerDaemonRecovery, reportLegacyGatewayStartResultFailure } =
require("./onboard/gateway-start-failure") as typeof import("./onboard/gateway-start-failure");
const dockerDriverGatewayEnv: typeof import("./onboard/docker-driver-gateway-env") =
require("./onboard/docker-driver-gateway-env");
const { getDockerDriverGatewayEndpoint } = dockerDriverGatewayEnv;
Expand Down Expand Up @@ -1257,6 +1259,7 @@ function destroyGateway(

type FinalGatewayStartFailureOptions = {
retries: number;
dockerUnreachable?: boolean;
collectDiagnostics?: () => string | null | undefined;
cleanupGateway?: () => void;
exitProcess?: (code: number) => never;
Expand All @@ -1265,6 +1268,7 @@ type FinalGatewayStartFailureOptions = {

function handleFinalGatewayStartFailure({
retries,
dockerUnreachable = false,
collectDiagnostics = () =>
runCaptureOpenshell(["doctor", "logs", "--name", GATEWAY_NAME], {
ignoreError: true,
Expand All @@ -1274,6 +1278,11 @@ function handleFinalGatewayStartFailure({
exitProcess = (code) => process.exit(code),
printError = (message = "") => console.error(message),
}: FinalGatewayStartFailureOptions): never {
if (dockerUnreachable) {
printDockerDaemonRecovery(printError);
return exitProcess(1);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

printError(` Gateway failed to start after ${retries + 1} attempts.`);
printError(" Gateway state preserved until diagnostics are collected.");
printError("");
Expand Down Expand Up @@ -2244,22 +2253,16 @@ async function startGatewayWithOptions(
);
}

// When a stale gateway is detected (metadata exists but container is gone,
// e.g. after a Docker/Colima restart), skip the destroy — `gateway start`
// can recover the container without wiping metadata and mTLS certs.
// The retry loop below will destroy only if start genuinely fails.
if (hasStaleGateway(gatewaySnapshot.gwInfo)) {
console.log(" Stale gateway detected — attempting restart without destroy...");
}

// Clear stale SSH host keys from previous gateway (fixes #768)
try {
const { execFileSync } = require("child_process");
execFileSync("ssh-keygen", ["-R", `openshell-${GATEWAY_NAME}`], { stdio: "ignore" });
} catch {
/* ssh-keygen -R may fail if entry doesn't exist — safe to ignore */
}
// Also purge any known_hosts entries matching the gateway hostname pattern
const knownHostsPath = path.join(os.homedir(), ".ssh", "known_hosts");
try {
const kh = fs.readFileSync(knownHostsPath, "utf8");
Expand All @@ -2270,9 +2273,6 @@ async function startGatewayWithOptions(
}

const gwArgs = ["--name", GATEWAY_NAME, "--port", getGatewayPortArg()];
// On NVIDIA hosts, pass --gpu unless the user explicitly opted out. This
// makes direct CUDA tools available in the sandbox by default while still
// supporting host-side inference providers.
if (gpuPassthrough) {
gwArgs.push("--gpu");
}
Expand All @@ -2281,12 +2281,8 @@ async function startGatewayWithOptions(
console.log(` Using pinned OpenShell gateway image: ${gatewayEnv.OPENSHELL_CLUSTER_IMAGE}`);
}

// Retry gateway start with exponential backoff. On some hosts (Horde VMs,
// first-run environments) the embedded k3s needs more time than OpenShell's
// internal health-check window allows. Retrying after a clean destroy lets
// the second attempt benefit from cached images and cleaner cgroup state.
// See: https://github.com/NVIDIA/OpenShell/issues/433
const retries = exitOnFailure ? 2 : 0;
let dockerUnreachable = false;
try {
await pRetry(
async () => {
Expand All @@ -2298,13 +2294,15 @@ async function startGatewayWithOptions(
},
);
if (startResult.status !== 0) {
const lines = String(redact(startResult.output || ""))
.split("\n")
.map((l) => compactText(l.replace(ANSI_RE, "")))
.filter(Boolean)
.map((l) => ` ${l}`);
if (lines.length > 0) {
console.log(` Gateway start returned before healthy:\n${lines.join("\n")}`);
const failure = reportLegacyGatewayStartResultFailure(
startResult.output || "",
console.log,
);
if (failure.kind === "docker_unreachable") {
dockerUnreachable = true;
throw new pRetry.AbortError(
"Docker daemon is not reachable (gateway cannot start).",
);
}
}
console.log(" Waiting for gateway health...");
Expand Down Expand Up @@ -2365,7 +2363,7 @@ async function startGatewayWithOptions(
);
} catch {
if (exitOnFailure) {
handleFinalGatewayStartFailure({ retries });
handleFinalGatewayStartFailure({ retries, dockerUnreachable });
}
throw new Error("Gateway failed to start");
}
Expand Down
48 changes: 39 additions & 9 deletions src/lib/onboard/docker-driver-gateway-failure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,9 @@ describe("reportDockerDriverGatewayStartFailure (#3111)", () => {
beforeEach(() => {
errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
// Stub process.exit so assertions can still run.
exitSpy = vi
.spyOn(process, "exit")
.mockImplementation(((code?: number) => {
throw new Error(`process.exit(${code})`);
}) as never);
exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`process.exit(${code})`);
}) as never);
});

afterEach(() => {
Expand All @@ -50,7 +48,9 @@ describe("reportDockerDriverGatewayStartFailure (#3111)", () => {
{ exitOnFailure: false },
),
).not.toThrow();
const joined = errSpy.mock.calls.map((c: string[]) => c.join(" ")).join("\n");
const joined = errSpy.mock.calls
.map((c: string[]) => c.join(" "))
.join("\n");
expect(joined).toContain("Docker-driver gateway failed to start");
expect(joined).toContain("Troubleshooting:");
expect(joined).toContain("tail -100 /tmp/nonexistent-gateway.log");
Expand All @@ -67,7 +67,9 @@ describe("reportDockerDriverGatewayStartFailure (#3111)", () => {
}),
{ exitOnFailure: false },
);
const joined = errSpy.mock.calls.map((c: string[]) => c.join(" ")).join("\n");
const joined = errSpy.mock.calls
.map((c: string[]) => c.join(" "))
.join("\n");
expect(joined).toContain(
"Gateway process exited with code 127 before becoming ready",
);
Expand All @@ -79,7 +81,9 @@ describe("reportDockerDriverGatewayStartFailure (#3111)", () => {
makeExitState(),
{ exitOnFailure: false },
);
const joined = errSpy.mock.calls.map((c: string[]) => c.join(" ")).join("\n");
const joined = errSpy.mock.calls
.map((c: string[]) => c.join(" "))
.join("\n");
expect(joined).not.toContain("before becoming ready");
});

Expand All @@ -94,14 +98,40 @@ describe("reportDockerDriverGatewayStartFailure (#3111)", () => {
reportDockerDriverGatewayStartFailure(log, makeExitState(), {
exitOnFailure: false,
});
const joined = errSpy.mock.calls.map((c: string[]) => c.join(" ")).join("\n");
const joined = errSpy.mock.calls
.map((c: string[]) => c.join(" "))
.join("\n");
expect(joined).toContain("Gateway log tail");
expect(joined).toContain("GLIBC_2.38 not found");
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});

it("prints Docker runtime recovery guidance when gateway log shows Docker is unreachable (#2347)", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gw-fail-"));
const log = path.join(dir, "openshell-gateway.log");
fs.writeFileSync(
log,
[
"Error: Failed to create Docker client.",
"Socket not found: /var/run/docker.sock",
].join("\n"),
);
try {
reportDockerDriverGatewayStartFailure(log, makeExitState(), {
exitOnFailure: false,
});
const joined = errSpy.mock.calls
.map((c: string[]) => c.join(" "))
.join("\n");
expect(joined).toContain("Docker daemon is not running");
expect(joined).toContain("Start Docker, then rerun `nemoclaw onboard`");
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});

it("calls process.exit(1) when exitOnFailure is true", () => {
expect(() =>
reportDockerDriverGatewayStartFailure(
Expand Down
5 changes: 5 additions & 0 deletions src/lib/onboard/docker-driver-gateway-failure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@
import fs from "node:fs";

import { redact } from "../security/redact";
import { classifyGatewayStartFailure } from "../validation";

import type { ChildExitState } from "./child-exit-tracker";
import { printDockerDaemonRecovery } from "./gateway-start-failure";

export type ReportDockerDriverGatewayStartFailureOpts = {
/**
Expand Down Expand Up @@ -71,6 +73,9 @@ export function reportDockerDriverGatewayStartFailure(
console.error(" Gateway log tail:");
for (const line of tail.split("\n")) console.error(` ${redact(line)}`);
}
if (classifyGatewayStartFailure(tail).kind === "docker_unreachable") {
printDockerDaemonRecovery(console.error);
}
console.error(" Troubleshooting:");
console.error(` tail -100 ${logPath}`);
console.error(" docker info --format '{{json .CDISpecDirs}}'");
Expand Down
80 changes: 80 additions & 0 deletions src/lib/onboard/gateway-start-failure.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { describe, expect, it, vi } from "vitest";

import { classifyGatewayStartFailure } from "../validation";
import { reportLegacyGatewayStartResultFailure } from "./gateway-start-failure";

describe("classifyGatewayStartFailure", () => {
// Regression: NemoClaw #2347. When Colima is stopped on macOS, the
// openshell gateway-start stream prints "Failed to create Docker client.
// Socket not found: /var/run/docker.sock" before exiting non-zero. Onboard
// must short-circuit the retry loop with an actionable message instead of
// burning ~15 minutes on health polls against a dead socket.
it("detects colima-stopped signature on macOS (Socket not found)", () => {
const output = [
" Error: Failed to create Docker client.",
" Socket not found: /var/run/docker.sock",
].join("\n");
expect(classifyGatewayStartFailure(output)).toEqual({ kind: "docker_unreachable" });
});

it("detects dockerd-stopped signature on Linux (Cannot connect to the Docker daemon)", () => {
const output =
"Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?";
expect(classifyGatewayStartFailure(output)).toEqual({ kind: "docker_unreachable" });
});

it("detects the standalone 'Failed to create Docker client' error marker", () => {
expect(classifyGatewayStartFailure("Failed to create Docker client")).toEqual({
kind: "docker_unreachable",
});
});

it("does not match historical mentions of 'Failed to create Docker client'", () => {
const output =
"client created successfully; previous Failed to create Docker client issue fixed";
expect(classifyGatewayStartFailure(output)).toEqual({ kind: "unknown" });
});

it("detects free-form 'docker daemon is not running' wording", () => {
expect(classifyGatewayStartFailure("the docker daemon is not running on this host")).toEqual({
kind: "docker_unreachable",
});
});

it("returns unknown for healthy-but-slow output (should not short-circuit)", () => {
// Real output seen during a slow first-time k3s bootstrap — the retry
// loop must stay engaged for these, so we must not misclassify them.
const output = [
"Applying HelmChart openshell",
"openshell-0 still starting",
"Observed pod startup duration 90s",
].join("\n");
expect(classifyGatewayStartFailure(output)).toEqual({ kind: "unknown" });
});

it("returns unknown for empty or missing output", () => {
expect(classifyGatewayStartFailure("")).toEqual({ kind: "unknown" });
expect(classifyGatewayStartFailure()).toEqual({ kind: "unknown" });
});
});

describe("reportLegacyGatewayStartResultFailure", () => {
it("classifies Docker-unreachable output after stripping ANSI sequences (#2347)", () => {
const log = vi.fn();
const output = [
"\x1b[31mError: Failed to create Docker client.\x1b[0m",
"\x1b[33mSocket not found: /var/run/docker.sock\x1b[0m",
].join("\n");

expect(reportLegacyGatewayStartResultFailure(output, log)).toEqual({
kind: "docker_unreachable",
});
expect(log).toHaveBeenCalledWith(
expect.stringContaining("Gateway start returned before healthy"),
);
expect(log.mock.calls[0][0]).not.toContain("\x1b");
});
});
Loading
Loading