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
10 changes: 10 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,16 @@ RUN if [ "$NEMOCLAW_DARWIN_VM_COMPAT" = "1" ]; then \
chmod a+rw /sandbox/.nemoclaw/config.json; \
fi

# Health check: poll the gateway's /health endpoint so Docker (and Compose)
# can detect and restart unhealthy containers in standalone deployments.
# Ref: https://github.com/NVIDIA/NemoClaw/issues/1430
HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --retries=3 \
CMD port="${NEMOCLAW_DASHBOARD_PORT:-${OPENCLAW_GATEWAY_PORT:-}}"; \
if [ -z "$port" ]; then \
port="$(python3 -c 'import os; from urllib.parse import urlparse; raw = os.environ.get("CHAT_UI_URL") or "http://127.0.0.1:18789"; raw = raw if "://" in raw else "http://" + raw; u = urlparse(raw); print(u.port or 18789)' 2>/dev/null || printf '18789')"; \
fi; \
curl -sf "http://127.0.0.1:${port}/health"

Comment thread
coderabbitai[bot] marked this conversation as resolved.
# Entrypoint runs as root to start the gateway as the gateway user,
# then drops to sandbox for agent commands. See nemoclaw-start.sh.
ENTRYPOINT ["/usr/local/bin/nemoclaw-start"]
Expand Down
8 changes: 8 additions & 0 deletions Dockerfile.base
Original file line number Diff line number Diff line change
Expand Up @@ -209,3 +209,11 @@ RUN openclaw plugins install '@tencent-weixin/openclaw-weixin@2.4.2' --pin \
# hadolint ignore=DL3002
USER root
WORKDIR /

# Baseline health check. The base image runs no service, so this only
# verifies the Node.js runtime is functional. Child images that expose
# a service (e.g. the production Dockerfile's gateway) MUST override
# this with a service-specific probe; otherwise an unresponsive service
# will still report healthy.
HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --retries=3 \
CMD node -e "process.exit(0)"
5 changes: 5 additions & 0 deletions test/Dockerfile.sandbox
Original file line number Diff line number Diff line change
Expand Up @@ -105,4 +105,9 @@ RUN mkdir -p /sandbox/openclaw-state/extensions /sandbox/openclaw-state/skills /
&& printf '%s\n' '---' 'name: override-hook' 'description: Override hook fixture' '---' > /sandbox/openclaw-state/hooks/demo-hook/HOOK.md \
&& printf '%s\n' 'export default async function overrideHook() {}' > /sandbox/openclaw-state/hooks/demo-hook/handler.js

# Test image: no long-running service, so just verify the runtime works.
# Overrides any inherited HEALTHCHECK to avoid false-positive probes.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD node -e "process.exit(0)"

ENTRYPOINT ["/bin/bash"]
143 changes: 141 additions & 2 deletions test/sandbox-provisioning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,40 @@ function dockerRunCommandBetween(
.replace(/\\\n/g, " ");
}

function dockerHealthCommandBetween(
dockerfile: string,
startMarker: string,
endMarker?: string,
): string {
const start = dockerfile.indexOf(startMarker);
const end = endMarker ? dockerfile.indexOf(endMarker, start) : dockerfile.length;
if (start === -1 || end === -1 || end <= start) {
throw new Error(`Expected Dockerfile health check after ${startMarker}`);
}
const healthOffset = dockerfile.slice(start, end).search(/^HEALTHCHECK\b/m);
const healthIndex = healthOffset === -1 ? -1 : start + healthOffset;
if (healthIndex === -1) {
throw new Error(`Expected HEALTHCHECK instruction after ${startMarker}`);
}
const healthLines: string[] = [];
for (const line of dockerfile.slice(healthIndex, end).split("\n")) {
healthLines.push(line);
if (!line.trimEnd().endsWith("\\")) {
break;
}
}
const lastLine = healthLines[healthLines.length - 1]?.trimEnd() ?? "";
if (lastLine.endsWith("\\")) {
throw new Error(`Expected complete HEALTHCHECK instruction after ${startMarker}`);
}
const instruction = healthLines.join("\n").trim().replace(/\\\n/g, " ");
const command = instruction.match(/(?:^|\s)CMD\s+([\s\S]+)$/)?.[1];
if (!command) {
throw new Error(`Expected shell-form HEALTHCHECK CMD after ${startMarker}`);
}
return command.trim();
}

function runDockerShell(command: string, sandboxRoot: string) {
const logPath = path.join(sandboxRoot, "calls.log");
fs.rmSync(logPath, { force: true });
Expand All @@ -78,7 +112,12 @@ function runDockerShell(command: string, sandboxRoot: string) {
return { result, calls };
}

function runLoggedDockerShell(command: string, tmp: string, functionDefs: string[] = []) {
function runLoggedDockerShell(
command: string,
tmp: string,
functionDefs: string[] = [],
env: Record<string, string | undefined> = {},
) {
const logPath = path.join(tmp, "calls.log");
fs.rmSync(logPath, { force: true });
const script = [
Expand All @@ -90,11 +129,111 @@ function runLoggedDockerShell(command: string, tmp: string, functionDefs: string
].join("\n");
const scriptPath = path.join(tmp, "run-docker-block.sh");
fs.writeFileSync(scriptPath, script, { mode: 0o700 });
const result = spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 5000 });
const childEnv = { ...process.env };
for (const [key, value] of Object.entries(env)) {
if (value === undefined) {
delete childEnv[key];
} else {
childEnv[key] = value;
}
}
const result = spawnSync("bash", [scriptPath], {
encoding: "utf-8",
env: childEnv,
timeout: 5000,
});
const calls = fs.existsSync(logPath) ? fs.readFileSync(logPath, "utf-8") : "";
return { result, calls };
}

describe("sandbox provisioning: image health checks (#1430)", () => {
it.each([
["default dashboard URL", {}, "http://127.0.0.1:18789/health"],
[
"CHAT_UI_URL with scheme",
{ CHAT_UI_URL: "http://127.0.0.1:19000" },
"http://127.0.0.1:19000/health",
],
[
"CHAT_UI_URL without scheme",
{ CHAT_UI_URL: "remote-host:19111" },
"http://127.0.0.1:19111/health",
],
[
"OPENCLAW_GATEWAY_PORT",
{ CHAT_UI_URL: "http://127.0.0.1:19000", OPENCLAW_GATEWAY_PORT: "19333" },
"http://127.0.0.1:19333/health",
],
[
"NEMOCLAW_DASHBOARD_PORT override",
{
CHAT_UI_URL: "http://127.0.0.1:19000",
NEMOCLAW_DASHBOARD_PORT: "19222",
OPENCLAW_GATEWAY_PORT: "19333",
},
"http://127.0.0.1:19222/health",
],
])("routes production gateway probe through %s", (_label, env, expectedUrl) => {
const dockerfile = fs.readFileSync(DOCKERFILE, "utf-8");
const command = dockerHealthCommandBetween(
dockerfile,
"# Health check: poll the gateway's /health endpoint",
"# Entrypoint runs as root",
);
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-health-probe-"));

try {
const probe = runLoggedDockerShell(
command,
tmp,
['curl() { printf "%s\\n" "$*" >> "$call_log"; }'],
{
NEMOCLAW_DASHBOARD_PORT: undefined,
OPENCLAW_GATEWAY_PORT: undefined,
CHAT_UI_URL: undefined,
...env,
},
);

expect(probe.result.status).toBe(0);
expect(probe.calls).toContain("-sf");
expect(probe.calls).toContain(expectedUrl);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});

it.each([
[
"base image",
DOCKERFILE_BASE,
"# Baseline health check.",
undefined,
],
[
"test image",
DOCKERFILE_SANDBOX,
"# Test image: no long-running service",
"ENTRYPOINT",
],
])("keeps %s non-service probe runtime-only", (_label, imagePath, startMarker, endMarker) => {
const imageDefinition = fs.readFileSync(imagePath, "utf-8");
const command = dockerHealthCommandBetween(imageDefinition, startMarker, endMarker);
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-runtime-probe-"));

try {
const probe = runLoggedDockerShell(command, tmp, [
'curl() { printf "%s\\n" "$*" >> "$call_log"; return 42; }',
]);

expect(probe.result.status).toBe(0);
expect(probe.calls).toBe("");
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
});

describe("sandbox provisioning: unified .openclaw layout (#2227)", () => {
it("provisions unified mutable .openclaw layout and trusted rc shims", () => {
const dockerfile = fs.readFileSync(DOCKERFILE_BASE, "utf-8");
Expand Down
Loading