Skip to content
Draft
Show file tree
Hide file tree
Changes from 5 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
3 changes: 3 additions & 0 deletions agents/hermes/config/messaging-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ export function buildMessagingEnvLines(
if (allowedIds.telegram?.length) {
envLines.push(`TELEGRAM_ALLOWED_USERS=${allowedIds.telegram.map(String).join(",")}`);
}
if (allowedIds.slack?.length) {
envLines.push(`SLACK_ALLOWED_USERS=${allowedIds.slack.map(String).join(",")}`);
}

return envLines;
}
Expand Down
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
100 changes: 99 additions & 1 deletion src/lib/actions/sandbox/rebuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ import {
const { hydrateCredentialEnv } = require("../../onboard") as {
hydrateCredentialEnv: (name: string) => string | null;
};
const hermesProviderAuth = require("../../hermes-provider-auth") as {
HERMES_PROVIDER_NAME: string;
HERMES_NOUS_API_KEY_CREDENTIAL_ENV: string;
loadHermesOAuthState: (sandboxName: string) => {
auth_method?: unknown;
api_key?: unknown;
access_token?: unknown;
refresh_token?: unknown;
} | null;
};
const { LOCAL_INFERENCE_PROVIDERS, REMOTE_PROVIDER_CONFIG } = require("../../onboard/providers") as {
LOCAL_INFERENCE_PROVIDERS: string[];
REMOTE_PROVIDER_CONFIG: Record<string, { providerName: string; credentialEnv: string | null }>;
Expand Down Expand Up @@ -62,6 +72,74 @@ function getRebuildCredentialEnvFromRegistry(provider: string | null | undefined
return remoteConfig?.credentialEnv || null;
}

function normalizeHermesRebuildAuthMethod(value: unknown): "oauth" | "api_key" | null {
const normalized = String(value || "")
.trim()
.toLowerCase()
.replace(/[\s-]+/g, "_");
if (!normalized) return null;
if (normalized === "oauth" || normalized === "nous_oauth" || normalized === "nous_portal_oauth") {
return "oauth";
}
if (
normalized === "api" ||
normalized === "key" ||
normalized === "api_key" ||
normalized === "apikey" ||
normalized === "nous_api_key"
) {
return "api_key";
}
return null;
}

function nonEmptyString(value: unknown): string | null {
const normalized = String(value || "").trim();
return normalized || null;
}

function preflightHermesProviderCredentials(
sandboxName: string,
session: Session | null,
credentialEnv: string | null,
log: (msg: string) => void,
): boolean {
const state = hermesProviderAuth.loadHermesOAuthState(sandboxName);
const authMethod =
normalizeHermesRebuildAuthMethod(session?.hermesAuthMethod) ||
normalizeHermesRebuildAuthMethod(state?.auth_method) ||
(credentialEnv === hermesProviderAuth.HERMES_NOUS_API_KEY_CREDENTIAL_ENV ? "api_key" : "oauth");

if (authMethod === "api_key") {
const hostStateKey = nonEmptyString(state?.api_key) || nonEmptyString(state?.access_token);
const envKey = hydrateCredentialEnv(hermesProviderAuth.HERMES_NOUS_API_KEY_CREDENTIAL_ENV);
log(
`Hermes Provider rebuild preflight: api_key state=${hostStateKey ? "present" : "missing"} env=${envKey ? "present" : "missing"}`,
);
if (hostStateKey || envKey) return true;
} else {
const refreshToken = nonEmptyString(state?.refresh_token);
log(
`Hermes Provider rebuild preflight: oauth refresh_token=${refreshToken ? "present" : "missing"}`,
);
if (refreshToken) return true;
}

console.error("");
console.error(` ${_RD}Rebuild preflight failed:${R} Hermes Provider credentials not found.`);
console.error(" Hermes Provider uses host-side Nous auth state, not OPENAI_API_KEY.");
if (authMethod === "api_key") {
console.error(
` Re-run ${CLI_NAME} onboard to store a Nous API key, or export ${hermesProviderAuth.HERMES_NOUS_API_KEY_CREDENTIAL_ENV} before rebuilding.`,
);
} else {
console.error(` Re-run ${CLI_NAME} onboard to refresh Nous Portal OAuth for this sandbox.`);
}
console.error("");
console.error(" Sandbox is untouched — no data was lost.");
return false;
}

/**
* Rebuild a live sandbox while preserving registered agent state and policies.
*
Expand Down Expand Up @@ -161,8 +239,9 @@ export async function rebuildSandbox(
// credential when onboard runs in non-interactive mode. Checking now
// lets us abort with the sandbox still intact. See #2273.
const session = onboardSession.loadSession();
const sessionMatchesTarget = !session?.sandboxName || session.sandboxName === sandboxName;
let rebuildCredentialEnv: string | null = null;
if (session && session.sandboxName && session.sandboxName !== sandboxName) {
if (!sessionMatchesTarget) {
// Session belongs to a different sandbox — its credentialEnv may be
// wrong (e.g. hermes session while rebuilding openclaw). Resolve the
// target sandbox provider from the registry instead so destructive
Expand All @@ -178,6 +257,7 @@ export async function rebuildSandbox(
} else {
rebuildCredentialEnv = session?.credentialEnv || null;
}
const rebuildProvider = sessionMatchesTarget ? session?.provider || sb.provider : sb.provider;
// Legacy migration: pre-fix local-inference sandboxes (GH #2519) recorded
// credentialEnv="OPENAI_API_KEY" in onboard-session.json even though the
// sandbox does not actually need a host OpenAI key (ollama-local uses an
Expand All @@ -197,6 +277,24 @@ export async function rebuildSandbox(
);
rebuildCredentialEnv = null;
}
if (rebuildProvider === hermesProviderAuth.HERMES_PROVIDER_NAME) {
if (
!preflightHermesProviderCredentials(
sandboxName,
sessionMatchesTarget ? session : null,
rebuildCredentialEnv,
log,
)
) {
bail("Missing Hermes Provider credentials");
return;
}
// Hermes Provider credentials are host-managed in ~/.nemoclaw/hermes-oauth
// and re-registered by the provider setup path during recreate. Do not
// fall through to the generic env-var preflight, which would incorrectly
// demand OPENAI_API_KEY for OAuth or NOUS_API_KEY despite reusable state.
rebuildCredentialEnv = null;
}
if (rebuildCredentialEnv) {
// hydrateCredentialEnv migrates any pre-fix legacy credentials.json
// into process.env once, so users upgrading from a release that wrote
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
Loading
Loading