Skip to content
Draft
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
22 changes: 20 additions & 2 deletions agents/hermes/plugin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import os
import subprocess
import yaml
from urllib.parse import urlparse


def _load_nemoclaw_config():
Expand Down Expand Up @@ -47,6 +48,22 @@ def _load_hermes_config():
return None


def _get_gateway_port():
"""Return the sandbox-exposed Hermes API port."""
raw = os.environ.get("NEMOCLAW_DASHBOARD_PORT")
if raw and raw.isdigit():
return int(raw)
chat_url = os.environ.get("CHAT_UI_URL", "")
if chat_url:
try:
parsed = urlparse(chat_url)
if parsed.port:
return int(parsed.port)
except Exception:
pass
return 8642


def _get_sandbox_info():
"""Gather sandbox status information."""
hermes_cfg = _load_hermes_config()
Expand All @@ -67,10 +84,11 @@ def _get_sandbox_info():
provider = nemoclaw_cfg.get("provider", provider)

# Check gateway health
gateway_port = _get_gateway_port()
gateway_ok = False
try:
result = subprocess.run(
["curl", "-sf", "http://localhost:8642/health"],
["curl", "-sf", f"http://localhost:{gateway_port}/health"],
capture_output=True,
text=True,
timeout=5,
Expand All @@ -86,7 +104,7 @@ def _get_sandbox_info():
"provider": provider,
"base_url": base_url,
"gateway": "running" if gateway_ok else "stopped",
"port": 8642,
"port": gateway_port,
}


Expand Down
18 changes: 17 additions & 1 deletion agents/hermes/start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,23 @@ case "${1:-}" in
esac
NEMOCLAW_CMD=("$@")
CHAT_UI_URL="${CHAT_UI_URL:-http://127.0.0.1:8642}"
PUBLIC_PORT=8642
PUBLIC_PORT="${NEMOCLAW_DASHBOARD_PORT:-}"
if [ -z "$PUBLIC_PORT" ]; then
_chat_port="${CHAT_UI_URL##*:}"
_chat_port="${_chat_port%%/*}"
_chat_port="${_chat_port%%\?*}"
_chat_port="${_chat_port%%#*}"
case "$_chat_port" in
'' | *[!0-9]*) PUBLIC_PORT=8642 ;;
*) PUBLIC_PORT="$_chat_port" ;;
esac
fi
case "$PUBLIC_PORT" in
'' | *[!0-9]*) PUBLIC_PORT=8642 ;;
esac
export NEMOCLAW_DASHBOARD_PORT="$PUBLIC_PORT"
CHAT_UI_URL="http://127.0.0.1:${PUBLIC_PORT}"
export CHAT_UI_URL
# Hermes binds to 127.0.0.1 regardless of config (upstream bug).
# Run it on an internal port and use socat to expose on PUBLIC_PORT.
INTERNAL_PORT=18642
Expand Down
34 changes: 20 additions & 14 deletions src/lib/actions/sandbox/process-recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,20 +58,21 @@ export function resolveSandboxDashboardPort(
sandboxName: string,
deps: SandboxPortDeps = {},
): number {
const getSessionAgent = deps.getSessionAgent ?? agentRuntime.getSessionAgent;
const agent = getSessionAgent(sandboxName);
if (agent && isValidPort(agent.forwardPort)) {
return agent.forwardPort;
}

const getSandbox = deps.getSandbox ?? registry.getSandbox;
const sandbox = getSandbox(sandboxName);
return isValidPort(sandbox?.dashboardPort) ? sandbox.dashboardPort : DASHBOARD_PORT;
if (isValidPort(sandbox?.dashboardPort)) {
return sandbox.dashboardPort;
}

const getSessionAgent = deps.getSessionAgent ?? agentRuntime.getSessionAgent;
const agent = getSessionAgent(sandboxName);
return agent && isValidPort(agent.forwardPort) ? agent.forwardPort : DASHBOARD_PORT;
}

function getSandboxHealthProbeUrl(sandboxName: string): string {
const agent = agentRuntime.getSessionAgent(sandboxName);
if (agent) return agentRuntime.getHealthProbeUrl(agent);
const port = resolveSandboxDashboardPort(sandboxName);
if (agent) return agentRuntime.getHealthProbeUrlForPort(agent, port);
return `http://127.0.0.1:${resolveSandboxDashboardPort(sandboxName)}/health`;
}

Expand Down Expand Up @@ -301,6 +302,10 @@ function ensureSandboxPortForward(sandboxName: string): boolean {
return isSandboxForwardHealthy(sandboxName) === true;
}

function getPortForwardLabel(agent: unknown): string {
return agent ? `${agentRuntime.getAgentDisplayName(agent)} port forward` : "Dashboard port forward";
}

/**
* Probe `openshell forward list` for the sandbox's dashboard forward.
* Returns true when an entry exists for the expected sandbox+port pair
Expand Down Expand Up @@ -331,7 +336,7 @@ export function classifySandboxForwardHealth(
const match = entries.find((entry) => entry.port === port);
if (!match) return false;
if (match.sandboxName !== sandboxName) return "occupied";
return match.status === "running";
return match.status.includes("running") || match.status.includes("active");
}

/**
Expand All @@ -351,6 +356,7 @@ export function checkAndRecoverSandboxProcesses(
}
const recoveryAgent = agentRuntime.getSessionAgent(sandboxName);
const recoveryPort = resolveSandboxDashboardPort(sandboxName);
const forwardLabel = getPortForwardLabel(recoveryAgent);
if (running) {
// Gateway is alive but the host-side forward can still be dead or
// owned by another sandbox. Probe and re-establish only when
Expand All @@ -359,15 +365,15 @@ export function checkAndRecoverSandboxProcesses(
if (forwardHealthy === false) {
if (!quiet) {
console.log("");
console.log(` Dashboard port forward to '${sandboxName}' is missing or dead.`);
console.log(` ${forwardLabel} to '${sandboxName}' is missing or dead.`);
console.log(" Re-establishing...");
}
const forwardRecovered = ensureSandboxPortForward(sandboxName);
if (!quiet) {
if (forwardRecovered) {
console.log(` ${G}✓${R} Dashboard port forward re-established.`);
console.log(` ${G}✓${R} ${forwardLabel} re-established.`);
} else {
console.error(" Failed to re-establish the dashboard port forward.");
console.error(` Failed to re-establish the ${forwardLabel.toLowerCase()}.`);
console.error(
` Run \`openshell forward start --background <port> ${sandboxName}\` manually.`,
);
Expand Down Expand Up @@ -416,9 +422,9 @@ export function checkAndRecoverSandboxProcesses(
` ${G}✓${R} ${agentRuntime.getAgentDisplayName(recoveryAgent)} gateway restarted inside sandbox.`,
);
if (forwardRecovered) {
console.log(` ${G}✓${R} Dashboard port forward re-established.`);
console.log(` ${G}✓${R} ${forwardLabel} re-established.`);
} else {
console.error(" Failed to re-establish the dashboard port forward.");
console.error(` Failed to re-establish the ${forwardLabel.toLowerCase()}.`);
console.error(
` Run \`openshell forward start --background <port> ${sandboxName}\` manually.`,
);
Expand Down
20 changes: 15 additions & 5 deletions src/lib/agent/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
buildManualRecoveryCommand,
buildOpenClawRecoveryScript,
buildRecoveryScript,
getHealthProbeUrlForPort,
} from "../../../dist/lib/agent/runtime";
import type { AgentDefinition } from "./defs";

Expand Down Expand Up @@ -93,14 +94,21 @@ describe("buildRecoveryScript", () => {
});

it("omits --port for Hermes so config.yaml controls the internal listen port (#2426)", () => {
const script = buildRecoveryScript(hermesAgent, 8642);
const script = buildRecoveryScript(hermesAgent, 18790);
expect(script).toContain("export HERMES_HOME=/sandbox/.hermes");
expect(script).toContain("HERMES_HOME=/sandbox/.hermes");
expect(script).toContain("NEMOCLAW_DASHBOARD_PORT=18790");
expect(script).toContain("CHAT_UI_URL=http://127.0.0.1:18790");
expect(script).toContain("HTTPS_PROXY=http://127.0.0.1:3129");
expect(script).toContain("http://localhost:18790/health");
expect(script).toContain("nemoclaw-decode-proxy");
expect(script).toContain('"$AGENT_BIN" gateway run');
expect(script).not.toContain('"$AGENT_BIN" gateway run --port 8642');
expect(script).not.toContain("hermes gateway run --port 8642");
expect(script).not.toContain('"$AGENT_BIN" gateway run --port 18790');
expect(script).not.toContain("hermes gateway run --port 18790");
});

it("rewrites health probe URLs to the active forwarded port", () => {
expect(getHealthProbeUrlForPort(hermesAgent, 18790)).toBe("http://localhost:18790/health");
});

it("falls back to openclaw gateway run when gateway_command is absent", () => {
Expand Down Expand Up @@ -314,12 +322,14 @@ describe("buildManualRecoveryCommand (#2426)", () => {
});

it("omits --port for Hermes and uses the current Hermes home", () => {
const cmd = buildManualRecoveryCommand(hermesAgent, 8642);
const cmd = buildManualRecoveryCommand(hermesAgent, 18790);
expect(cmd).toContain("HERMES_HOME=/sandbox/.hermes");
expect(cmd).toContain("NEMOCLAW_DASHBOARD_PORT=18790");
expect(cmd).toContain("CHAT_UI_URL=http://127.0.0.1:18790");
expect(cmd).toContain("HTTPS_PROXY=http://127.0.0.1:3129");
expect(cmd).toContain("nemoclaw-decode-proxy");
expect(cmd).toContain("nohup hermes gateway run");
expect(cmd).not.toContain("--port 8642");
expect(cmd).not.toContain("--port 18790");
expect(cmd).not.toContain("/sandbox/.hermes-data");
});

Expand Down
33 changes: 27 additions & 6 deletions src/lib/agent/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,18 @@ export function getHealthProbeUrl(agent: AgentDefinition | null): string {
return agent.healthProbe?.url || `http://127.0.0.1:${DASHBOARD_PORT}/health`;
}

export function getHealthProbeUrlForPort(agent: AgentDefinition | null, port: number): string {
const probeUrl = getHealthProbeUrl(agent);
if (!Number.isInteger(port) || port < 1 || port > 65535) return probeUrl;
try {
const parsed = new URL(probeUrl);
parsed.port = String(port);
return parsed.toString();
} catch {
return probeUrl.replace(/:(\d+)(?=\/|$)/, `:${String(port)}`);
}
}

function escapeEre(value: string): string {
return value.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
}
Expand Down Expand Up @@ -142,15 +154,20 @@ function gatewayLaunchCommand(command: string, runAsUser?: string): string {
return `${logSelection} if [ "$(id -u)" = "0" ] && command -v gosu >/dev/null 2>&1 && id ${shellQuote(runAsUser)} >/dev/null 2>&1; then nohup gosu ${shellQuote(runAsUser)} ${command} >> "$_GATEWAY_LOG" 2>&1 & else ${userLaunch} fi;`;
}

function hermesGatewayEnvPrefix(): string {
function hermesGatewayEnvPrefix(port?: number): string {
const decodeProxy = "http://127.0.0.1:3129";
return [
const envVars = [
"HERMES_HOME=/sandbox/.hermes",
`HTTPS_PROXY=${decodeProxy}`,
`HTTP_PROXY=${decodeProxy}`,
`https_proxy=${decodeProxy}`,
`http_proxy=${decodeProxy}`,
].join(" ");
];
if (typeof port === "number" && Number.isInteger(port) && port >= 1 && port <= 65535) {
envVars.push(`NEMOCLAW_DASHBOARD_PORT=${String(port)}`);
envVars.push(`CHAT_UI_URL=http://127.0.0.1:${String(port)}`);
}
return envVars.join(" ");
}

function hermesDecodeProxyRecoveryCommand(): string {
Expand Down Expand Up @@ -190,7 +207,7 @@ export function buildOpenClawRecoveryScript(port: number): string {
export function buildRecoveryScript(agent: AgentDefinition | null, port: number): string | null {
if (!agent) return null;

const probeUrl = getHealthProbeUrl(agent);
const probeUrl = getHealthProbeUrlForPort(agent, port);
const binaryPath = agent.binary_path || "/usr/local/bin/openclaw";
const binaryName = binaryPath.split("/").pop() ?? "openclaw";
const defaultGatewayCommand = `${binaryName} gateway run`;
Expand All @@ -214,7 +231,7 @@ export function buildRecoveryScript(agent: AgentDefinition | null, port: number)
// that's about to crash on a missing guard. (#2478)
const isHermes = agent.name === "hermes";
const hermesHome = isHermes ? "export HERMES_HOME=/sandbox/.hermes; " : "";
const hermesLaunchEnv = isHermes ? `env ${hermesGatewayEnvPrefix()} ` : "";
const hermesLaunchEnv = isHermes ? `env ${hermesGatewayEnvPrefix(port)} ` : "";
const launchCommand = usesValidatedBinary
? gatewayLaunchCommand(`${hermesLaunchEnv}"$AGENT_BIN" gateway run${isHermes ? "" : ` --port ${port}`}`)
: gatewayLaunchCommand(
Expand Down Expand Up @@ -262,6 +279,10 @@ export function getGatewayCommand(agent: AgentDefinition | null): string {
return agent?.gateway_command || "openclaw gateway run";
}

export function getAgentForwardPort(agent: AgentDefinition | null): number {
return agent?.forwardPort ?? DASHBOARD_PORT;
}

/**
* Build a single copy-pasteable command for the user to run when automatic
* gateway recovery fails. Unlike the raw gateway command, this keeps the
Expand All @@ -272,7 +293,7 @@ export function buildManualRecoveryCommand(agent: AgentDefinition | null, port:
const defaultGatewayCommand = `${shellQuote(binaryPath)} gateway run`;
const gatewayCmd = agent?.gateway_command?.trim() || defaultGatewayCommand;
const isHermes = agent?.name === "hermes";
const envPrefix = isHermes ? `${hermesGatewayEnvPrefix()} ` : "";
const envPrefix = isHermes ? `${hermesGatewayEnvPrefix(port)} ` : "";
const portFlag = isHermes ? "" : ` --port ${port}`;
const decodeProxySetup = isHermes ? `${hermesDecodeProxyRecoveryCommand()} ` : "";
return `${buildGatewayLogSelection()} ${decodeProxySetup}${envPrefix}nohup ${gatewayCmd}${portFlag} >> "$_GATEWAY_LOG" 2>&1 &`;
Expand Down
23 changes: 23 additions & 0 deletions test/hermes-plugin-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,29 @@ function runPython(script: string): string {
}

describe("Hermes NemoClaw plugin handlers", () => {
it("reads the active Hermes API port from the sandbox environment", () => {
const output = runPython(`
import importlib.util
import os
import pathlib
import sys
import types

plugin_path = pathlib.Path(sys.argv[1])
yaml_stub = types.ModuleType("yaml")
yaml_stub.safe_load = lambda *_args, **_kwargs: {}
sys.modules.setdefault("yaml", yaml_stub)
spec = importlib.util.spec_from_file_location("hermes_plugin", plugin_path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)

os.environ["NEMOCLAW_DASHBOARD_PORT"] = "18790"
print(module._get_gateway_port())
`);

expect(output.trim()).toBe("18790");
});

it("accepts Hermes dispatch kwargs for status, info, and reload handlers", () => {
const output = runPython(`
import importlib.util
Expand Down
11 changes: 10 additions & 1 deletion test/process-recovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,21 @@ describe("resolveSandboxDashboardPort", () => {
).toBe(18789);
});

it("keeps non-OpenClaw agents on their declared forward port", () => {
it("uses the recorded dashboard port for non-OpenClaw agents when present", () => {
expect(
resolveSandboxDashboardPort("hermes-box", {
getSessionAgent: () => ({ forwardPort: 8642 }),
getSandbox: () => ({ name: "hermes-box", dashboardPort: 18790 }),
}),
).toBe(18790);
});

it("falls back to a non-OpenClaw agent's declared forward port without registry metadata", () => {
expect(
resolveSandboxDashboardPort("hermes-box", {
getSessionAgent: () => ({ forwardPort: 8642 }),
getSandbox: () => null,
}),
).toBe(8642);
});

Expand Down
24 changes: 24 additions & 0 deletions test/recover-port-forward.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ function setupFixture(opts: {
* reporting the original dead/missing state — models a failed restart. */
forwardStartHeals?: boolean;
port?: string;
agentName?: string;
}): Fixture {
const sandboxName = opts.sandboxName;
const port = opts.port ?? "18789";
Expand All @@ -55,6 +56,7 @@ function setupFixture(opts: {
provider: "nvidia-prod",
gpuEnabled: false,
policies: [],
agent: opts.agentName,
dashboardPort: Number(port),
},
},
Expand Down Expand Up @@ -240,4 +242,26 @@ describe("nemoclaw <name> recover", () => {
expect(calls.some((l) => l.startsWith("forward start "))).toBe(false);
},
);

it(
"uses the registry dashboard port for Hermes recovery instead of the manifest default",
testTimeoutOptions(20_000),
() => {
const fixture = setupFixture({
sandboxName: "hermes-sandbox",
gatewayProbe: "RUNNING",
forwardListStatus: "dead",
port: "18790",
agentName: "hermes",
});
const result = runRecover(fixture);
expect(result.status).toBe(0);

const calls = fs.readFileSync(fixture.invocationLog, "utf-8").split("\n");
expect(calls.some((l) => l.startsWith("forward stop 18790"))).toBe(true);
expect(calls.some((l) => l.startsWith("forward start --background 18790"))).toBe(true);
expect(calls.some((l) => l.startsWith("forward stop 8642"))).toBe(false);
expect(calls.some((l) => l.startsWith("forward start --background 8642"))).toBe(false);
},
);
});
Loading
Loading