Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
71 changes: 70 additions & 1 deletion src/lib/agent-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@

import { describe, it, expect } from "vitest";
// Import from compiled dist/ so coverage is attributed correctly.
import { buildOpenClawRecoveryScript, buildRecoveryScript } from "../../dist/lib/agent-runtime";
import {
buildManualRecoveryCommand,
buildOpenClawRecoveryScript,
buildRecoveryScript,
} from "../../dist/lib/agent-runtime";
import type { AgentDefinition } from "./agent-defs";

function makeAgent(overrides: Partial<AgentDefinition> = {}): AgentDefinition {
Expand Down Expand Up @@ -41,6 +45,20 @@ function makeAgent(overrides: Partial<AgentDefinition> = {}): AgentDefinition {
}

const minimalAgent = makeAgent();
const hermesAgent = makeAgent({
name: "hermes",
displayName: "Hermes Agent",
binary_path: "/usr/local/bin/hermes",
gateway_command: "hermes gateway run",
healthProbe: { url: "http://localhost:8642/health", port: 8642, timeout_seconds: 90 },
forwardPort: 8642,
configPaths: {
dir: "/sandbox/.hermes",
configFile: "/sandbox/.hermes/config.yaml",
envFile: "/sandbox/.hermes/.env",
format: "yaml",
},
});

function extractGatewayProcessPattern(script: string | null): string {
const match = script?.match(/_GATEWAY_PROC_PATTERN='([^']+)'/);
Expand Down Expand Up @@ -73,6 +91,14 @@ describe("buildRecoveryScript", () => {
expect(script).toContain('"$AGENT_BIN" gateway run --port 19000');
});

it("omits --port for Hermes so config.yaml controls the internal listen port (#2426)", () => {
const script = buildRecoveryScript(hermesAgent, 8642);
expect(script).toContain("export HERMES_HOME=/sandbox/.hermes");
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");
});

it("falls back to openclaw gateway run when gateway_command is absent", () => {
const agent = makeAgent({ gateway_command: undefined });
const script = buildRecoveryScript(agent, 19000);
Expand All @@ -93,6 +119,16 @@ describe("buildRecoveryScript", () => {
expect(script).toContain("nohup custom-launch --mode recovery --port 19000");
});

it("does not append the external forward port to custom Hermes launch commands (#2426)", () => {
const agent = makeAgent({
...hermesAgent,
gateway_command: "hermes gateway run --profile recovery",
});
const script = buildRecoveryScript(agent, 8642);
expect(script).toContain("nohup hermes gateway run --profile recovery");
expect(script).not.toContain("hermes gateway run --profile recovery --port 8642");
});

// Regression coverage for #2478. The recovery script must explicitly source
// /tmp/nemoclaw-proxy-env.sh (single source of truth for NODE_OPTIONS
// library guards) and warn — not silently continue — when the file is
Expand Down Expand Up @@ -257,3 +293,36 @@ describe("buildRecoveryScript", () => {
});
});
});

describe("buildManualRecoveryCommand (#2426)", () => {
it("backgrounds non-Hermes gateways with nohup and the requested port", () => {
const cmd = buildManualRecoveryCommand(minimalAgent, 19000);
expect(cmd).toContain("nohup test-agent gateway run --port 19000");
expect(cmd).toContain('>> "$_GATEWAY_LOG" 2>&1 &');
});

it("selects a writable gateway log before launching", () => {
const cmd = buildManualRecoveryCommand(minimalAgent, 19000);
expect(cmd).toContain("_GATEWAY_LOG=/tmp/gateway.log");
expect(cmd).toContain("_GATEWAY_LOG=/tmp/gateway-recovery.log");
expect(cmd).not.toContain(">/tmp/gateway.log 2>&1");
});

it("omits --port for Hermes and uses the current Hermes home", () => {
const cmd = buildManualRecoveryCommand(hermesAgent, 8642);
expect(cmd).toContain("HERMES_HOME=/sandbox/.hermes nohup hermes gateway run");
expect(cmd).not.toContain("--port 8642");
expect(cmd).not.toContain("/sandbox/.hermes-data");
});

it("derives the default gateway command from binary_path when gateway_command is blank", () => {
const agent = makeAgent({ gateway_command: " " });
const cmd = buildManualRecoveryCommand(agent, 19000);
expect(cmd).toContain("nohup test-agent gateway run --port 19000");
});

it("falls back to openclaw gateway run for a null agent", () => {
const cmd = buildManualRecoveryCommand(null, 18789);
expect(cmd).toContain("nohup openclaw gateway run --port 18789");
});
});
22 changes: 19 additions & 3 deletions src/lib/agent-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,11 +193,11 @@ export function buildRecoveryScript(agent: AgentDefinition | null, port: number)
// survive past the gateway launch — otherwise the warning explaining
// *why* the gateway is about to crash gets wiped by the same launch
// that's about to crash on a missing guard. (#2478)
const launchCommand = usesValidatedBinary
? gatewayLaunchCommand(`"$AGENT_BIN" gateway run --port ${port}`)
: gatewayLaunchCommand(`${configuredGatewayCommand} --port ${port}`);
const isHermes = agent.name === "hermes";
const hermesHome = isHermes ? "export HERMES_HOME=/sandbox/.hermes; " : "";
const launchCommand = usesValidatedBinary
? gatewayLaunchCommand(`"$AGENT_BIN" gateway run${isHermes ? "" : ` --port ${port}`}`)
: gatewayLaunchCommand(`${configuredGatewayCommand}${isHermes ? "" : ` --port ${port}`}`);

// Source /tmp/nemoclaw-proxy-env.sh immediately before launching. That file
// is the single source of truth for NODE_OPTIONS preload guards (safety-net,
Expand Down Expand Up @@ -238,3 +238,19 @@ export function getAgentDisplayName(agent: AgentDefinition | null): string {
export function getGatewayCommand(agent: AgentDefinition | null): string {
return agent?.gateway_command || "openclaw gateway run";
}

/**
* Build a single copy-pasteable command for the user to run when automatic
* gateway recovery fails. Unlike the raw gateway command, this keeps the
* process alive after disconnect and preserves the agent-specific launch shape.
*/
export function buildManualRecoveryCommand(agent: AgentDefinition | null, port: number): string {
const binaryPath = agent?.binary_path || "/usr/local/bin/openclaw";
const binaryName = binaryPath.split("/").pop() ?? "openclaw";
const defaultGatewayCommand = `${binaryName} gateway run`;
const gatewayCmd = agent?.gateway_command?.trim() || defaultGatewayCommand;
const isHermes = agent?.name === "hermes";
const envPrefix = isHermes ? "HERMES_HOME=/sandbox/.hermes " : "";
const portFlag = isHermes ? "" : ` --port ${port}`;
return `${buildGatewayLogSelection()} ${envPrefix}nohup ${gatewayCmd}${portFlag} >> "$_GATEWAY_LOG" 2>&1 &`;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
12 changes: 11 additions & 1 deletion src/nemoclaw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,8 +427,13 @@ function checkAndRecoverSandboxProcesses(
// recovered process can be alive before the OpenAI-compatible API is ready.
if (!waitForRecoveredSandboxGateway(sandboxName)) {
if (!quiet) {
const _recoveryPort = _recoveryAgent?.forwardPort ?? DASHBOARD_PORT;
console.error(" Gateway process started but is not responding.");
console.error(" Check /tmp/gateway.log inside the sandbox for details.");
console.error(" Connect to the sandbox and run manually:");
console.error(
` ${agentRuntime.buildManualRecoveryCommand(_recoveryAgent, _recoveryPort)}`,
);
}
return { checked: true, wasRunning: false, recovered: false };
}
Expand All @@ -444,7 +449,12 @@ function checkAndRecoverSandboxProcesses(
` Could not restart ${agentRuntime.getAgentDisplayName(_recoveryAgent)} gateway automatically.`,
);
console.error(" Connect to the sandbox and run manually:");
console.error(` ${agentRuntime.getGatewayCommand(_recoveryAgent)}`);
console.error(
` ${agentRuntime.buildManualRecoveryCommand(
_recoveryAgent,
_recoveryAgent?.forwardPort ?? DASHBOARD_PORT,
)}`,
);
}

return { checked: true, wasRunning: false, recovered };
Expand Down
Loading