Skip to content
5 changes: 4 additions & 1 deletion docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2720,6 +2720,8 @@ OpenClaw caches skill content per session, so the command also refreshes the Ope

Hermes plugins are different from NemoClaw skills.
`skill install` uploads agent skills, while Hermes plugin configuration is managed by the Hermes runtime and the NemoClaw Hermes plugin baked into the sandbox image.
The NemoClaw Hermes plugin reloads installed skills when a new chat session starts.
Start a new Hermes chat session after an install or update; a gateway restart is not required.

</AgentOnly>
<AgentOnly variant="deepagents">
Expand Down Expand Up @@ -2766,7 +2768,8 @@ For OpenClaw, the command also removes the OpenClaw home-directory mirror when p
</AgentOnly>
<AgentOnly variant="hermes">

Run `$$nemoclaw <name> gateway restart` if prompted so the removal takes effect.
Start a new Hermes chat session for the removal to take effect.
A gateway restart is not required.

</AgentOnly>
<AgentOnly variant="deepagents">
Expand Down
2 changes: 2 additions & 0 deletions src/lib/actions/sandbox/skill-install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const paths = {
mirrorDir: "$HOME/.openclaw/skills/demo-skill",
uploadDirSharedWithAgent: false,
sessionFile: "/sandbox/.openclaw/agents/main/sessions/sessions.json",
reloadsSkillsOnSessionStart: false,
isOpenClaw: true,
};

Expand All @@ -59,6 +60,7 @@ const sharedPaths = {
mirrorDir: null,
uploadDirSharedWithAgent: true,
sessionFile: null,
reloadsSkillsOnSessionStart: false,
isOpenClaw: false,
};

Expand Down
2 changes: 1 addition & 1 deletion src/lib/actions/sandbox/skill-install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,7 @@ export async function installSandboxSkill(
}
console.log(` ${G}✓${R} Uploaded ${uploaded} file(s) to sandbox`);

// 7. Post-install (OpenClaw mirror + refresh, or restart hint).
// 7. Post-install (OpenClaw mirror + refresh, or agent-specific activation guidance).
// OpenClaw caches skill content per session, so always refresh the
// session index after an install/update to avoid stale SKILL.md data.
const post = skillInstall.postInstall(ctx, paths, skillDir);
Expand Down
27 changes: 27 additions & 0 deletions src/lib/skill-install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ describe("resolveSkillPaths", () => {
expect(paths.mirrorDir).toBe("$HOME/.openclaw/skills/weather");
expect(paths.uploadDirSharedWithAgent).toBe(false);
expect(paths.sessionFile).toBe("/sandbox/.openclaw/agents/main/sessions/sessions.json");
expect(paths.reloadsSkillsOnSessionStart).toBe(false);
expect(paths.isOpenClaw).toBe(true);
});

Expand All @@ -234,6 +235,7 @@ describe("resolveSkillPaths", () => {
expect(paths.mirrorDir).toBe("$HOME/.openclaw/skills/my-skill");
expect(paths.uploadDirSharedWithAgent).toBe(false);
expect(paths.sessionFile).toBe("/sandbox/.openclaw/agents/main/sessions/sessions.json");
expect(paths.reloadsSkillsOnSessionStart).toBe(false);
expect(paths.isOpenClaw).toBe(true);
});

Expand All @@ -250,6 +252,7 @@ describe("resolveSkillPaths", () => {
expect(paths.mirrorDir).toBeNull();
expect(paths.uploadDirSharedWithAgent).toBe(false);
expect(paths.sessionFile).toBeNull();
expect(paths.reloadsSkillsOnSessionStart).toBe(true);
expect(paths.isOpenClaw).toBe(false);
});

Expand All @@ -269,6 +272,7 @@ describe("resolveSkillPaths", () => {
expect(paths.mirrorDir).toBeNull();
expect(paths.uploadDirSharedWithAgent).toBe(true);
expect(paths.sessionFile).toBeNull();
expect(paths.reloadsSkillsOnSessionStart).toBe(false);
expect(paths.isOpenClaw).toBe(false);
});

Expand All @@ -285,11 +289,34 @@ describe("resolveSkillPaths", () => {
expect(paths.mirrorDir).toBeNull();
expect(paths.uploadDirSharedWithAgent).toBe(false);
expect(paths.sessionFile).toBeNull();
expect(paths.reloadsSkillsOnSessionStart).toBe(false);
expect(paths.isOpenClaw).toBe(false);
});
});

describe("postInstall", () => {
it("tells Hermes users to start a fresh session without restarting the gateway", () => {
const paths = resolveSkillPaths(
{ name: "hermes", configPaths: { dir: "/sandbox/.hermes" } },
"weather",
);
const result = postInstall(
{ configFile: "/tmp/ssh-config", sandboxName: "alpha" },
paths,
"/unused",
{
sshExecImpl: () => {
throw new Error("Hermes activation must not require an SSH mutation");
},
},
);

expect(result).toEqual({
success: true,
messages: ["Start a new chat session to load the skill; a gateway restart is not required."],
});
});

it("refreshes OpenClaw sessions after installing an updated skill", () => {
const skillDir = mkdtempSync(join(tmpdir(), "skill-postinstall-"));
const commands: string[] = [];
Expand Down
11 changes: 9 additions & 2 deletions src/lib/skill-install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ export interface SkillPaths {
uploadDirSharedWithAgent: boolean;
/** OpenClaw-only: session index to clear, or null */
sessionFile: string | null;
/** Whether a fresh agent session reloads skills without a gateway restart */
reloadsSkillsOnSessionStart: boolean;
/** Whether the agent is OpenClaw (drives refresh behavior) */
isOpenClaw: boolean;
}
Expand Down Expand Up @@ -164,6 +166,7 @@ export function resolveSkillPaths(
mirrorDir: mirror ? mirror(dir, skillName) : null,
uploadDirSharedWithAgent: Boolean(sharedDir),
sessionFile: isOpenClaw ? `${dir}/agents/main/sessions/sessions.json` : null,
reloadsSkillsOnSessionStart: agentName === "hermes",
isOpenClaw,
};
}
Expand Down Expand Up @@ -597,7 +600,7 @@ export function installFreshSharedSkill(

/**
* Run post-install steps: skill-load mirror for every agent that needs one,
* session refresh for OpenClaw, and a restart hint when neither applies.
* session refresh for OpenClaw, and agent-specific activation guidance.
*/
export function postInstall(
ctx: SshContext,
Expand Down Expand Up @@ -645,7 +648,11 @@ export function postInstall(
}

if (!paths.mirrorDir && !paths.sessionFile) {
messages.push("Restart the agent gateway to pick up the new skill.");
messages.push(
paths.reloadsSkillsOnSessionStart
? "Start a new chat session to load the skill; a gateway restart is not required."
: "Restart the agent gateway to pick up the new skill.",
);
}

return { success: true, messages };
Expand Down
21 changes: 21 additions & 0 deletions src/lib/skill-remote.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,27 @@ describe("removeSkill (unit — no SSH)", () => {
]);
});

it("tells Hermes users to start a fresh session after removal", () => {
const ctx = { configFile: "/tmp/ssh.conf", sandboxName: "test-sandbox" };
const paths = resolveSkillPaths(
{ name: "hermes", configPaths: { dir: "/sandbox/.hermes" } },
"test-skill",
);
const commands: string[] = [];
const result = removeSkill(ctx, paths, {
sshExecImpl: (_ctx, command) => {
commands.push(command);
return { status: 0, stdout: "", stderr: "" };
},
});

expect(result.success).toBe(true);
expect(result.messages).toEqual([
"Start a new chat session for the removal to take effect; a gateway restart is not required.",
]);
expect(commands).toEqual(["rm -rf '/sandbox/.hermes/skills/test-skill'"]);
});

it("probes the canonical Deep Agents directory for diagnostics (#7634)", () => {
const ctx = { configFile: "/tmp/ssh.conf", sandboxName: "test-sandbox" };
const paths = resolveSkillPaths(
Expand Down
6 changes: 5 additions & 1 deletion src/lib/skill-remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,11 @@ export function removeSkill(
}

if (!paths.mirrorDir && !paths.sessionFile) {
messages.push("Restart the agent gateway for the removal to take effect.");
messages.push(
paths.reloadsSkillsOnSessionStart
? "Start a new chat session for the removal to take effect; a gateway restart is not required."
: "Restart the agent gateway for the removal to take effect.",
);
}

return {
Expand Down
5 changes: 5 additions & 0 deletions test/e2e/fixtures/fake-openai-compatible.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export interface FakeOpenAiCompatibleRequest {
readonly model?: string;
readonly stream?: boolean;
readonly forbiddenMarkerMatches?: number;
/** Presence only; the configured non-secret canary is never persisted. */
readonly requestCanaryPresent?: boolean;
}

export interface FakeOpenAiCompatibleServer {
Expand All @@ -38,6 +40,8 @@ export interface FakeOpenAiCompatibleServerOptions {
readonly apiKey?: string;
readonly chatContent?: string;
readonly forbiddenMarkers?: readonly string[];
/** Non-secret marker expected in a request under test. */
readonly requestCanaryMarker?: string;
readonly host?: string;
readonly maxModelLen?: number;
readonly model?: string;
Expand Down Expand Up @@ -185,6 +189,7 @@ export async function startFakeOpenAiCompatibleServer(
NEMOCLAW_FAKE_OPENAI_MODEL: options.model ?? "test-model",
NEMOCLAW_FAKE_OPENAI_PORT: String(options.port ?? 0),
NEMOCLAW_FAKE_OPENAI_PORT_FILE: portFile,
NEMOCLAW_FAKE_OPENAI_REQUEST_CANARY_MARKER: options.requestCanaryMarker ?? "",
NEMOCLAW_FAKE_OPENAI_REQUESTS_FILE: requestsFile,
NEMOCLAW_FAKE_OPENAI_REQUIRE_AUTH: options.requireAuth ? "1" : "0",
NEMOCLAW_FAKE_OPENAI_REQUIRE_AUTH_MODELS: options.requireAuthModels ? "1" : "0",
Expand Down
13 changes: 13 additions & 0 deletions test/e2e/fixtures/hermes-skill-runtime/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
name: nemoclaw-hermes-skill-e2e
description: Verifies Hermes skill discovery and fresh-session execution
---

# Hermes skill runtime verification

When this skill is selected, do not use tools. Reply with exactly `PONG` and nothing else.

The following non-secret test canary must remain in the model-visible skill content:
`NEMOCLAW_E2E_REQUEST_CANARY_K9X2`

Do not include the canary in your response.
16 changes: 16 additions & 0 deletions test/e2e/fixtures/inference-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { ArtifactSink } from "./artifacts.ts";
import { buildAvailabilityProbeEnv } from "./availability-env.ts";
import { type ProviderClient, trustedProviderEndpoint } from "./clients/provider.ts";
import {
type FakeOpenAiCompatibleRequest,
type FakeOpenAiCompatibleServer,
startFakeOpenAiCompatibleServer,
} from "./fake-openai-compatible.ts";
Expand Down Expand Up @@ -42,6 +43,9 @@ import type { TestProgress, TestProgressCapability } from "./progress.ts";
export const E2E_INFERENCE_MODE_VALUES = ["mock", "internal-nvidia", "public-nvidia"] as const;
export type E2EInferenceMode = (typeof E2E_INFERENCE_MODE_VALUES)[number];

/** Non-secret marker used to prove fixture content reached the local mock inference boundary. */
export const E2E_MOCK_REQUEST_CANARY = "NEMOCLAW_E2E_REQUEST_CANARY_K9X2";

export interface E2EInferenceAdapter {
readonly mode: E2EInferenceMode;
readonly model: string;
Expand All @@ -52,6 +56,8 @@ export interface E2EInferenceAdapter {
readonly contractLabel: string;
env(extra?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
redactionValues(): string[];
/** Privacy-safe mock request metadata; unavailable when inference is hosted. */
requestSummaries(): readonly FakeOpenAiCompatibleRequest[] | undefined;
probeModels(artifactName: string): Promise<unknown>;
directChat(
prompt: string,
Expand Down Expand Up @@ -219,6 +225,10 @@ class OpenAiCompatibleInferenceAdapter implements E2EInferenceAdapter {
return [this.apiKey];
}

requestSummaries(): readonly FakeOpenAiCompatibleRequest[] | undefined {
return this.fake?.requests();
}

async probeModels(artifactName: string): Promise<unknown> {
if (this.providerClient) {
return requestViaProvider(this.providerClient, {
Expand Down Expand Up @@ -326,6 +336,10 @@ class PublicNvidiaInferenceAdapter implements E2EInferenceAdapter {
return [this.apiKey];
}

requestSummaries(): undefined {
return undefined;
}

async probeModels(artifactName: string): Promise<unknown> {
return requestViaProvider(this.providerClient, {
allowedHosts: PUBLIC_NVIDIA_ALLOWED_HOSTS,
Expand Down Expand Up @@ -370,6 +384,8 @@ export async function createE2EInferenceAdapter(
const fake = await startFakeOpenAiCompatibleServer({
apiKey,
chatContent: "PONG",
// The fake stores only presence metadata, never request bodies or the canary value.
requestCanaryMarker: E2E_MOCK_REQUEST_CANARY,
// A Docker network namespace cannot reach host loopback through the host
// alias, so listen on the bridge-facing interfaces. The workflow uses an
// ephemeral ubuntu-latest VM, an OS-assigned port, and a per-run credential.
Expand Down
10 changes: 10 additions & 0 deletions test/e2e/lib/fake-openai-compatible-api.mts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const requireAuth = process.env.NEMOCLAW_FAKE_OPENAI_REQUIRE_AUTH === "1";
const requireAuthModels = process.env.NEMOCLAW_FAKE_OPENAI_REQUIRE_AUTH_MODELS === "1";
const chatContent = process.env.NEMOCLAW_FAKE_OPENAI_CHAT_CONTENT || "ok";
const responseText = process.env.NEMOCLAW_FAKE_OPENAI_RESPONSE_TEXT || chatContent;
const requestCanaryMarker = process.env.NEMOCLAW_FAKE_OPENAI_REQUEST_CANARY_MARKER || "";
const forbiddenMarkers = (() => {
try {
const parsed = JSON.parse(process.env.NEMOCLAW_FAKE_OPENAI_FORBIDDEN_MARKERS || "[]");
Expand Down Expand Up @@ -137,6 +138,13 @@ function forbiddenMarkerMatches(req: IncomingMessage, raw: Buffer): number {
return forbiddenMarkers.filter((marker) => requestMaterial.includes(marker)).length;
}

function requestCanaryPresent(req: IncomingMessage, raw: Buffer): boolean | undefined {
if (!requestCanaryMarker) return undefined;
const headerValues = Object.values(req.headers).flatMap((value) => value ?? []);
const requestMaterial = [req.url ?? "", ...headerValues, raw.toString("utf8")].join("\n");
return requestMaterial.includes(requestCanaryMarker);
}

const server = createServer(async (req, res) => {
const path = requestPath(req);

Expand All @@ -153,6 +161,7 @@ const server = createServer(async (req, res) => {
// credential without leaking it into the requests log (#6177).
authorizationSent: Boolean(req.headers.authorization),
forbiddenMarkerMatches: forbiddenMarkerMatches(req, Buffer.alloc(0)),
requestCanaryPresent: requestCanaryPresent(req, Buffer.alloc(0)),
});
if (!modelsAuthOk) {
sendJson(res, 401, { error: { message: "missing bearer credential" } });
Expand All @@ -178,6 +187,7 @@ const server = createServer(async (req, res) => {
model: payload.model,
stream: Boolean(payload.stream),
forbiddenMarkerMatches: forbiddenMarkerMatches(req, raw),
requestCanaryPresent: requestCanaryPresent(req, raw),
});

if (req.method === "POST" && ["/v1/chat/completions", "/chat/completions"].includes(path)) {
Expand Down
2 changes: 1 addition & 1 deletion test/e2e/live/hermes-e2e-phases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
export const HERMES_E2E_PHASES = [
"prepare clean Hermes runner",
"install and onboard Hermes sandbox",
"validate sandbox layout and health",
"validate sandbox layout, health, and skill activation",
"restart Hermes gateway, validate supervision, and launch a turn",
"exercise hosted and inference.local routes",
"validate CLI manifest and locked-config behavior",
Expand Down
14 changes: 13 additions & 1 deletion test/e2e/live/hermes-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
import type { ShellProbeResult } from "../fixtures/shell-probe.ts";
import { assertHermesCliAdapterLiveContract, stripAnsi } from "./hermes-cli-adapter-live.ts";
import { HERMES_E2E_PHASES } from "./hermes-e2e-phases.ts";
import { assertHermesSkillLifecycle } from "./hermes-skill-lifecycle.ts";
import { runLaunchAgentTurn } from "./launch-agent-turn.ts";
import { expectPackageDatabaseReadOnly } from "./package-database-read-only.ts";

Expand Down Expand Up @@ -376,7 +377,7 @@ test("hermes-e2e: install.sh onboards Hermes and proves health plus live inferen
expect(resultText(install)).toContain(`http://127.0.0.1:${HERMES_DASHBOARD_PORT}/`);
}

progress.phase("validate sandbox layout and health");
progress.phase("validate sandbox layout, health, and skill activation");
// Phase 3: sandbox verification.
const list = await host.command("nemoclaw", ["list"], {
artifactName: "phase-3-nemoclaw-list",
Expand Down Expand Up @@ -500,6 +501,14 @@ test("hermes-e2e: install.sh onboards Hermes and proves health plus live inferen
expect(configProbe.exitCode, resultText(configProbe)).toBe(0);
expect(configProbe.stdout).toContain("OK");

await assertHermesSkillLifecycle({
env: commandEnv(),
host,
inference,
redactionValues,
sandboxName: SANDBOX_NAME,
});

await assertHermesCliAdapterLiveContract({
env: commandEnv(),
redactionValues,
Expand Down Expand Up @@ -1443,6 +1452,9 @@ test("hermes-e2e: install.sh onboards Hermes and proves health plus live inferen
sandboxListedAndHealthy: true,
directProviderInferencePong: true,
sandboxInferenceLocalPong: true,
hermesSkillInstalled: true,
hermesSkillDiscovered: true,
hermesSkillUsedInFreshSession: true,
dashboardChecked: hermesDashboardE2eEnabled(),
securityPostureChecked: securityPosture !== null,
},
Expand Down
Loading
Loading