diff --git a/agents/hermes/plugin/__init__.py b/agents/hermes/plugin/__init__.py index 5437c8ec18b..6cce694923d 100644 --- a/agents/hermes/plugin/__init__.py +++ b/agents/hermes/plugin/__init__.py @@ -3,8 +3,15 @@ """ NemoClaw plugin for Hermes Agent. -Provides sandbox status tools and a startup banner when Hermes runs inside -an OpenShell sandbox managed by NemoClaw. +Provides sandbox status tools, skill hot-reload, and a startup banner when +Hermes runs inside an OpenShell sandbox managed by NemoClaw. + +Skill hot-reload: Hermes caches its skill slash-command registry in a +module-global dict on first scan. New skills dropped on disk are invisible +until the cache is cleared. This plugin provides a nemoclaw_reload_skills +tool that clears the cache and re-scans, letting the agent pick up new +skills without a gateway restart. The on_session_start hook also refreshes +skills automatically at session boundaries. """ import json @@ -104,6 +111,49 @@ def _handle_info(tool_input, context): return json.dumps(_get_sandbox_info(), indent=2) +def _reload_skills(): + """Clear the Hermes skill slash-command cache and re-scan skill directories. + + Hermes's ``agent.skill_commands`` module caches discovered skills in a + module-global dict (``_skill_commands``). ``get_skill_commands()`` only + scans on first call, so skills installed after gateway startup are + invisible. We clear the dict and call ``scan_skill_commands()`` to force + a fresh scan. + + Returns the dict of discovered skills, or None on failure. + """ + try: + import agent.skill_commands as sc + + sc._skill_commands.clear() + return sc.scan_skill_commands() + except ImportError: + return None + except Exception: + return None + + +def _handle_reload_skills(tool_input, context): + """Handle the nemoclaw_reload_skills tool call.""" + commands = _reload_skills() + if commands is None: + return ( + "Failed to reload skills. The agent.skill_commands module may " + "not be available in this Hermes version." + ) + + if not commands: + return "Skill reload complete. No skills found in skill directories." + + names = sorted(commands.keys()) + lines = [f"Skill reload complete. {len(names)} skill(s) discovered:", ""] + for name in names: + info = commands[name] + desc = info.get("description", "no description") + lines.append(f" {name}: {desc}") + return "\n".join(lines) + + def register(ctx): """Register NemoClaw tools and hooks with Hermes.""" @@ -142,8 +192,32 @@ def register(ctx): description="NemoClaw sandbox info (JSON)", ) + # Register skill reload tool + ctx.register_tool( + name="nemoclaw_reload_skills", + toolset="nemoclaw", + schema={ + "type": "function", + "function": { + "name": "nemoclaw_reload_skills", + "description": ( + "Reload and re-discover skills from the skill directories. " + "Call this after new skills have been installed to make them " + "available as slash commands without restarting the gateway." + ), + "parameters": {"type": "object", "properties": {}}, + }, + }, + handler=_handle_reload_skills, + description="Reload skills from disk without gateway restart", + ) + # Startup banner on session start def _on_session_start(**kwargs): + # Refresh skill cache so skills installed since last session are + # immediately available as slash commands. + _reload_skills() + info = _get_sandbox_info() banner = ( "\n" @@ -153,7 +227,8 @@ def _on_session_start(**kwargs): f" \u2502 Model: {info['model']:<40}\u2502\n" f" \u2502 Provider: {info['provider']:<40}\u2502\n" f" \u2502 Gateway: {info['gateway']:<40}\u2502\n" - " \u2502 Tools: nemoclaw_status, nemoclaw_info \u2502\n" + " \u2502 Tools: nemoclaw_status, nemoclaw_info, \u2502\n" + " \u2502 nemoclaw_reload_skills \u2502\n" " \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n" ) try: diff --git a/agents/hermes/plugin/plugin.yaml b/agents/hermes/plugin/plugin.yaml index ee2ed9ec59e..e9ba0916311 100644 --- a/agents/hermes/plugin/plugin.yaml +++ b/agents/hermes/plugin/plugin.yaml @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 name: nemoclaw -version: "0.0.11" +version: "0.0.12" description: "NemoClaw sandbox management for Hermes running inside OpenShell" author: "NVIDIA Corporation" manifest_version: 1 @@ -10,6 +10,7 @@ manifest_version: 1 provides_tools: - nemoclaw_status - nemoclaw_info + - nemoclaw_reload_skills provides_hooks: - on_session_start diff --git a/src/lib/skill-install.test.ts b/src/lib/skill-install.test.ts new file mode 100644 index 00000000000..d46108798fb --- /dev/null +++ b/src/lib/skill-install.test.ts @@ -0,0 +1,233 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect } from "vitest"; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +// Import from compiled dist/ so coverage is attributed correctly. +import { + parseFrontmatter, + resolveSkillPaths, + collectFiles, + validateRelativePath, + shellQuote, +} from "../../dist/lib/skill-install"; + +describe("parseFrontmatter", () => { + it("extracts name from valid frontmatter", () => { + const result = parseFrontmatter("---\nname: my-skill\ndescription: test\n---\n# Body"); + expect(result).toEqual({ name: "my-skill" }); + }); + + it("handles quoted name values", () => { + expect(parseFrontmatter('---\nname: "my-tool"\n---\n').name).toBe("my-tool"); + expect(parseFrontmatter("---\nname: 'demo.tool'\n---\n").name).toBe("demo.tool"); + }); + + it("handles name with dots, hyphens, and underscores", () => { + expect(parseFrontmatter("---\nname: my_skill.v2-beta\n---\n").name).toBe("my_skill.v2-beta"); + }); + + it("parses complex YAML metadata beyond name", () => { + const fm = parseFrontmatter( + '---\nname: rich-skill\ndescription: "A skill"\nmetadata: { "openclaw": { "emoji": "🔧" } }\n---\n', + ); + expect(fm.name).toBe("rich-skill"); + }); + + it("rejects malformed YAML", () => { + expect(() => + parseFrontmatter("---\nname: ok\ndescription: [broken\n---\n"), + ).toThrow("not valid YAML"); + }); + + it("rejects non-mapping frontmatter", () => { + expect(() => parseFrontmatter("---\n- just\n- a list\n---\n")).toThrow("must be a YAML mapping"); + }); + + it("throws when frontmatter is missing entirely", () => { + expect(() => parseFrontmatter("# Just markdown\nNo frontmatter")).toThrow( + "missing YAML frontmatter", + ); + }); + + it("throws when closing delimiter is missing", () => { + expect(() => parseFrontmatter("---\nname: broken\n# No closing")).toThrow( + "missing closing --- frontmatter delimiter", + ); + }); + + it("throws when name field is absent", () => { + expect(() => parseFrontmatter("---\ndescription: no name here\n---\n")).toThrow( + "missing required 'name' field", + ); + }); + + it("throws when name field is empty or null", () => { + expect(() => parseFrontmatter("---\nname:\n---\n")).toThrow("missing required 'name' field"); + expect(() => parseFrontmatter('---\nname: ""\n---\n')).toThrow("missing required 'name' field"); + }); + + it("rejects names with invalid characters", () => { + expect(() => parseFrontmatter("---\nname: my skill\n---\n")).toThrow("invalid characters"); + expect(() => parseFrontmatter("---\nname: ../escape\n---\n")).toThrow("invalid characters"); + expect(() => parseFrontmatter("---\nname: a/b\n---\n")).toThrow("invalid characters"); + }); +}); + +describe("validateRelativePath", () => { + it("accepts safe paths", () => { + expect(validateRelativePath("SKILL.md")).toBe(true); + expect(validateRelativePath("scripts/helper.js")).toBe(true); + expect(validateRelativePath("data/config-v2.yaml")).toBe(true); + }); + + it("rejects shell metacharacters", () => { + expect(validateRelativePath("$(touch /tmp/pwn).js")).toBe(false); + expect(validateRelativePath("a'b.txt")).toBe(false); + expect(validateRelativePath('a"b.txt')).toBe(false); + expect(validateRelativePath("a`b`.txt")).toBe(false); + expect(validateRelativePath("file name.txt")).toBe(false); + expect(validateRelativePath("a;rm -rf.txt")).toBe(false); + }); + + it("rejects directory traversal", () => { + expect(validateRelativePath("../escape")).toBe(false); + expect(validateRelativePath("foo/../../etc/passwd")).toBe(false); + expect(validateRelativePath("./current")).toBe(false); + }); + + it("rejects empty and degenerate paths", () => { + expect(validateRelativePath("")).toBe(false); + expect(validateRelativePath("/absolute")).toBe(false); + expect(validateRelativePath("foo//bar")).toBe(false); + }); +}); + +describe("shellQuote", () => { + it("wraps simple strings in single quotes", () => { + expect(shellQuote("hello")).toBe("'hello'"); + }); + + it("escapes embedded single quotes", () => { + expect(shellQuote("it's")).toBe("'it'\\''s'"); + }); +}); + +describe("collectFiles", () => { + let tmpDir: string; + + function setup(files: Record) { + tmpDir = mkdtempSync(join(tmpdir(), "skill-test-")); + for (const [rel, content] of Object.entries(files)) { + const full = join(tmpDir, rel); + mkdirSync(join(full, ".."), { recursive: true }); + writeFileSync(full, content); + } + } + + function cleanup() { + if (tmpDir) rmSync(tmpDir, { recursive: true, force: true }); + } + + it("collects a single SKILL.md", () => { + setup({ "SKILL.md": "---\nname: solo\n---\n" }); + try { + const { files, skippedDotfiles, unsafePaths } = collectFiles(tmpDir); + expect(files).toEqual(["SKILL.md"]); + expect(skippedDotfiles).toEqual([]); + expect(unsafePaths).toEqual([]); + } finally { + cleanup(); + } + }); + + it("collects SKILL.md plus nested scripts, skips dotfiles", () => { + setup({ + "SKILL.md": "---\nname: rich\n---\n", + "scripts/helper.js": "console.log('hi')", + ".env": "KEY=val", + }); + try { + const { files, skippedDotfiles } = collectFiles(tmpDir); + expect(files.sort()).toEqual(["SKILL.md", "scripts/helper.js"]); + expect(skippedDotfiles).toEqual([".env"]); + } finally { + cleanup(); + } + }); + + it("flags files with unsafe characters", () => { + setup({ + "SKILL.md": "---\nname: bad\n---\n", + "has space.txt": "content", + }); + try { + const { files, unsafePaths } = collectFiles(tmpDir); + expect(files).toEqual(["SKILL.md"]); + expect(unsafePaths).toEqual(["has space.txt"]); + } finally { + cleanup(); + } + }); +}); + +describe("resolveSkillPaths", () => { + it("returns OpenClaw defaults when agent is null", () => { + const paths = resolveSkillPaths(null, "weather"); + expect(paths.uploadDir).toBe("/sandbox/.openclaw/skills/weather"); + expect(paths.mirrorDir).toBe("$HOME/.openclaw/skills/weather"); + expect(paths.sessionFile).toBe( + "/sandbox/.openclaw-data/agents/main/sessions/sessions.json", + ); + expect(paths.isOpenClaw).toBe(true); + }); + + it("returns OpenClaw paths when agent.name is 'openclaw'", () => { + const agent = { + name: "openclaw", + configPaths: { + immutableDir: "/sandbox/.openclaw", + writableDir: "/sandbox/.openclaw-data", + }, + }; + const paths = resolveSkillPaths(agent, "my-skill"); + expect(paths.uploadDir).toBe("/sandbox/.openclaw/skills/my-skill"); + expect(paths.mirrorDir).toBe("$HOME/.openclaw/skills/my-skill"); + expect(paths.sessionFile).toBe( + "/sandbox/.openclaw-data/agents/main/sessions/sessions.json", + ); + expect(paths.isOpenClaw).toBe(true); + }); + + it("returns Hermes paths without mirror or session refresh", () => { + const agent = { + name: "hermes", + configPaths: { + immutableDir: "/sandbox/.hermes", + writableDir: "/sandbox/.hermes-data", + }, + }; + const paths = resolveSkillPaths(agent, "demo-skill"); + expect(paths.uploadDir).toBe("/sandbox/.hermes/skills/demo-skill"); + expect(paths.mirrorDir).toBeNull(); + expect(paths.sessionFile).toBeNull(); + expect(paths.isOpenClaw).toBe(false); + }); + + it("returns generic paths for a hypothetical future agent", () => { + const agent = { + name: "future-agent", + configPaths: { + immutableDir: "/sandbox/.future", + writableDir: "/sandbox/.future-data", + }, + }; + const paths = resolveSkillPaths(agent, "test-skill"); + expect(paths.uploadDir).toBe("/sandbox/.future/skills/test-skill"); + expect(paths.mirrorDir).toBeNull(); + expect(paths.sessionFile).toBeNull(); + expect(paths.isOpenClaw).toBe(false); + }); +}); diff --git a/src/lib/skill-install.ts b/src/lib/skill-install.ts new file mode 100644 index 00000000000..b84848cf96f --- /dev/null +++ b/src/lib/skill-install.ts @@ -0,0 +1,344 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Skill install logic for `nemoclaw skill install `. +// Validates a local SKILL.md, uploads it to the sandbox via SSH, and +// performs agent-specific post-install steps (OpenClaw mirror + session +// refresh). Non-OpenClaw agents get a "restart gateway" hint until a +// generic refresh contract is defined in the manifest schema. + +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; + +// yaml is a production dependency (used by policies.ts, onboard.ts) +import YAML from "yaml"; + +// ── Frontmatter parsing ────────────────────────────────────────── + +export interface SkillFrontmatter { + name: string; + [key: string]: unknown; +} + +/** + * Parse YAML frontmatter from a SKILL.md file content string. + * Expects `---\n...\n---` delimiters at the top of the file. + * Parses via the `yaml` library so malformed YAML is rejected. + * Returns the parsed frontmatter with at least a `name` field. + * Throws on missing delimiters, invalid YAML, missing `name`, or empty name. + */ +export function parseFrontmatter(content: string): SkillFrontmatter { + const lines = content.split("\n"); + if (lines[0]?.trim() !== "---") { + throw new Error("SKILL.md is missing YAML frontmatter (no opening --- delimiter)"); + } + + let closingIdx = lines.indexOf("---", 1); + if (closingIdx === -1) { + closingIdx = lines.findIndex((l, i) => i > 0 && l.trim() === "---"); + } + if (closingIdx === -1) { + throw new Error("SKILL.md is missing closing --- frontmatter delimiter"); + } + + const fmRaw = lines.slice(1, closingIdx).join("\n"); + + let parsed: unknown; + try { + parsed = YAML.parse(fmRaw); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error(`SKILL.md frontmatter is not valid YAML: ${msg}`); + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("SKILL.md frontmatter must be a YAML mapping (key: value pairs)"); + } + + const fm = parsed as Record; + const nameValue = typeof fm.name === "string" ? fm.name.trim() : ""; + if (!nameValue) { + throw new Error("SKILL.md frontmatter is missing required 'name' field"); + } + + if (!/^[A-Za-z0-9._-]+$/.test(nameValue)) { + throw new Error( + `SKILL.md name '${nameValue}' contains invalid characters. Only [A-Za-z0-9._-] allowed.`, + ); + } + + return { name: nameValue }; +} + +// ── Path resolution ────────────────────────────────────────────── + +export interface SkillPaths { + /** Primary upload target — the immutableDir/skills/{name}/ symlink path */ + uploadDir: string; + /** OpenClaw-only: $HOME/.openclaw/skills/{name}/ mirror path, or null */ + mirrorDir: string | null; + /** OpenClaw-only: session index to clear, or null */ + sessionFile: string | null; + /** Whether the agent is OpenClaw (drives mirror + refresh behavior) */ + isOpenClaw: boolean; +} + +/** + * Resolve skill install paths from the agent definition. + * @param agent - AgentDefinition from getSessionAgent(), or null for OpenClaw + * @param skillName - validated skill name from frontmatter + */ +export function resolveSkillPaths( + agent: { name: string; configPaths: { immutableDir: string; writableDir: string } } | null, + skillName: string, +): SkillPaths { + const isOpenClaw = !agent || agent.name === "openclaw"; + + const immutableDir = agent ? agent.configPaths.immutableDir : "/sandbox/.openclaw"; + const writableDir = agent ? agent.configPaths.writableDir : "/sandbox/.openclaw-data"; + + const uploadDir = `${immutableDir}/skills/${skillName}`; + + return { + uploadDir, + // Mirror uses $HOME at runtime — the sandbox user's home varies + // (/sandbox on stock images, /home/sandbox on some custom images). + // We expand $HOME in the SSH command rather than hardcoding a path. + mirrorDir: isOpenClaw ? `\$HOME/.openclaw/skills/${skillName}` : null, + sessionFile: isOpenClaw ? `${writableDir}/agents/main/sessions/sessions.json` : null, + isOpenClaw, + }; +} + +// ── Shell safety ───────────────────────────────────────────────── + +// Re-export shellQuote from runner.ts — a repo-wide test enforces +// a single definition lives in runner.ts. +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { shellQuote } = require("./runner"); +export { shellQuote }; + +const SAFE_PATH_RE = /^[A-Za-z0-9._\-/]+$/; + +/** + * Validate that a relative file path contains only safe characters. + * Rejects shell metacharacters, spaces, backticks, $, quotes, etc. + * Also rejects paths that escape the directory via `..`. + */ +export function validateRelativePath(rel: string): boolean { + if (!rel || !SAFE_PATH_RE.test(rel)) return false; + const segments = rel.split("/"); + return segments.every((s) => s !== "" && s !== ".." && s !== "."); +} + +// ── SSH helpers ────────────────────────────────────────────────── + +export interface SshContext { + configFile: string; + sandboxName: string; +} + +export interface SshResult { + status: number; + stdout: string; + stderr: string; +} + +/** + * Run a command on the sandbox via SSH with optional stdin content. + * Uses the same SSH flags as executeSandboxCommand in nemoclaw.ts. + */ +export function sshExec( + ctx: SshContext, + command: string, + opts: { input?: string | Buffer; timeout?: number } = {}, +): SshResult | null { + try { + const result = spawnSync( + "ssh", + [ + "-F", ctx.configFile, + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "ConnectTimeout=10", + "-o", "LogLevel=ERROR", + `openshell-${ctx.sandboxName}`, + command, + ], + { + encoding: "utf-8", + stdio: [opts.input !== undefined ? "pipe" : "ignore", "pipe", "pipe"], + input: opts.input, + timeout: opts.timeout ?? 30_000, + }, + ); + return { + status: result.status ?? 1, + stdout: (result.stdout || "").trim(), + stderr: (result.stderr || "").trim(), + }; + } catch { + return null; + } +} + +/** + * Upload a file to the sandbox by piping its content through SSH stdin. + * Creates the target directory and writes the file in a single remote command. + */ +export function uploadFile( + ctx: SshContext, + localPath: string, + remoteDir: string, + remoteFilename: string, +): SshResult | null { + const content = fs.readFileSync(localPath); + const remotePath = `${remoteDir}/${remoteFilename}`; + const script = `mkdir -p ${shellQuote(remoteDir)} && cat > ${shellQuote(remotePath)}`; + return sshExec(ctx, script, { input: content }); +} + +export interface CollectedFiles { + files: string[]; + skippedDotfiles: string[]; + unsafePaths: string[]; +} + +/** + * Collect files under `dir` recursively, returning paths relative to `dir`. + * Dotfiles (names starting with `.`) are excluded by default and reported + * separately so the caller can warn. Paths with unsafe characters are + * rejected to prevent shell injection when interpolated into SSH commands. + */ +export function collectFiles(dir: string): CollectedFiles { + const files: string[] = []; + const skippedDotfiles: string[] = []; + const unsafePaths: string[] = []; + + function walk(current: string, prefix: string) { + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const rel = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.name.startsWith(".")) { + if (entry.isFile()) skippedDotfiles.push(rel); + continue; + } + if (entry.isDirectory()) { + walk(path.join(current, entry.name), rel); + } else if (entry.isFile()) { + if (!validateRelativePath(rel)) { + unsafePaths.push(rel); + } else { + files.push(rel); + } + } + } + } + walk(dir, ""); + return { files, skippedDotfiles, unsafePaths }; +} + +/** + * Upload an entire skill directory to the sandbox, preserving subdirectory + * structure. Rejects files with unsafe path characters and skips dotfiles. + */ +export function uploadDirectory( + ctx: SshContext, + localDir: string, + remoteDir: string, +): { uploaded: number; failed: string[]; skippedDotfiles: string[]; unsafePaths: string[] } { + const { files, skippedDotfiles, unsafePaths } = collectFiles(localDir); + if (unsafePaths.length > 0) { + return { uploaded: 0, failed: unsafePaths, skippedDotfiles, unsafePaths }; + } + const failed: string[] = []; + for (const rel of files) { + const localFile = path.join(localDir, rel); + const remoteSubdir = rel.includes("/") + ? `${remoteDir}/${path.dirname(rel)}` + : remoteDir; + const result = uploadFile(ctx, localFile, remoteSubdir, path.basename(rel)); + if (!result || result.status !== 0) { + failed.push(rel); + } + } + return { uploaded: files.length - failed.length, failed, skippedDotfiles, unsafePaths }; +} + +/** + * Run post-install steps: OpenClaw mirror + session refresh, or + * non-OpenClaw restart hint. + * @param localSkillDir - the skill directory (contains SKILL.md and optional siblings) + */ +export function postInstall( + ctx: SshContext, + paths: SkillPaths, + localSkillDir: string, + opts: { skipRefresh?: boolean } = {}, +): { success: boolean; messages: string[] } { + const messages: string[] = []; + + if (paths.isOpenClaw) { + // Mirror to $HOME/.openclaw/skills/ — OpenClaw resolves skills from + // ~/.openclaw/skills/ via os.homedir(). Use double quotes so $HOME + // expands on the remote shell (the sandbox user's home varies: + // /sandbox on stock images, /home/sandbox on custom images). + if (paths.mirrorDir) { + const { files } = collectFiles(localSkillDir); + let mirrorFailed = false; + for (const rel of files) { + const content = fs.readFileSync(path.join(localSkillDir, rel)); + const mirrorSubdir = rel.includes("/") + ? `${paths.mirrorDir}/${path.dirname(rel)}` + : paths.mirrorDir; + const mirrorFile = `${paths.mirrorDir}/${rel}`; + // mirrorDir contains $HOME which must expand, so we use double + // quotes for the mkdir target but shellQuote the relative part + // to prevent injection from file names. + const result = sshExec( + ctx, + `mkdir -p "${mirrorSubdir}" && cat > "${mirrorFile}"`, + { input: content }, + ); + if (!result || result.status !== 0) { + mirrorFailed = true; + } + } + if (mirrorFailed) { + messages.push("Warning: failed to mirror some files to $HOME/.openclaw/skills/"); + } + } + + // Clear sessions.json so OpenClaw re-discovers skills on next session. + // Skip on updates — the agent already knows the skill, and clearing + // sessions would destroy chat history unnecessarily. + if (paths.sessionFile && !opts.skipRefresh) { + const refreshResult = sshExec(ctx, `printf '{}' > ${shellQuote(paths.sessionFile)}`); + if (!refreshResult || refreshResult.status !== 0) { + messages.push("Warning: failed to clear sessions (agent may need manual restart)"); + } + } + } else { + messages.push("Restart the agent gateway to pick up the new skill."); + } + + return { success: true, messages }; +} + +/** + * Check whether a skill already exists on the sandbox at the upload path. + */ +export function checkExisting(ctx: SshContext, paths: SkillPaths): boolean { + const target = shellQuote(`${paths.uploadDir}/SKILL.md`); + const result = sshExec(ctx, `test -f ${target} && echo EXISTS`); + return result !== null && result.stdout === "EXISTS"; +} + +/** + * Verify the SKILL.md file exists on the sandbox at the expected path. + */ +export function verifyInstall(ctx: SshContext, paths: SkillPaths): boolean { + const target = shellQuote(`${paths.uploadDir}/SKILL.md`); + const result = sshExec(ctx, `test -f ${target} && echo EXISTS`); + return result !== null && result.stdout === "EXISTS"; +} diff --git a/src/nemoclaw.ts b/src/nemoclaw.ts index e3c53f4dad4..dbf93b62782 100644 --- a/src/nemoclaw.ts +++ b/src/nemoclaw.ts @@ -67,6 +67,7 @@ const { runUninstallCommand, } = require("./lib/uninstall-command"); const agentRuntime = require("../bin/lib/agent-runtime"); +const skillInstall = require("./lib/skill-install"); // ── Global commands ────────────────────────────────────────────── @@ -1233,6 +1234,147 @@ function sandboxPolicyList(sandboxName) { console.log(""); } +async function sandboxSkillInstall(sandboxName, args = []) { + const sub = args[0]; + if (!sub || sub === "help" || sub === "--help" || sub === "-h") { + console.log(""); + console.log(" Usage: nemoclaw skill install "); + console.log(""); + console.log(" Deploy a skill directory to a running sandbox."); + console.log(" must be a skill directory containing a SKILL.md (with 'name:' frontmatter),"); + console.log(" or a direct path to a SKILL.md file. All non-dot files in the directory are uploaded."); + console.log(""); + return; + } + + if (sub !== "install") { + console.error(` Unknown skill subcommand: ${sub}`); + console.error(" Valid subcommands: install"); + process.exit(1); + } + + const skillPath = args[1]; + const extraArgs = args.slice(2); + if (extraArgs.length > 0) { + console.error(` Unknown argument(s) for skill install: ${extraArgs.join(", ")}`); + console.error(" Usage: nemoclaw skill install "); + process.exit(1); + } + if (!skillPath) { + console.error(" Usage: nemoclaw skill install "); + console.error(" must be a directory containing a SKILL.md file."); + process.exit(1); + } + + const resolvedPath = path.resolve(skillPath); + + // Accept a directory containing SKILL.md, or a direct path to SKILL.md. + let skillDir: string; + let skillMdPath: string; + if (fs.existsSync(resolvedPath) && fs.statSync(resolvedPath).isDirectory()) { + skillDir = resolvedPath; + skillMdPath = path.join(resolvedPath, "SKILL.md"); + } else if (fs.existsSync(resolvedPath) && resolvedPath.endsWith("SKILL.md")) { + skillDir = path.dirname(resolvedPath); + skillMdPath = resolvedPath; + } else { + console.error(` No SKILL.md found at '${resolvedPath}'.`); + console.error(" must be a skill directory or a direct path to SKILL.md."); + process.exit(1); + } + + if (!fs.existsSync(skillMdPath)) { + console.error(` No SKILL.md found in '${skillDir}'.`); + console.error(" The skill directory must contain a SKILL.md file."); + process.exit(1); + } + + // 1. Validate frontmatter + let frontmatter; + try { + const content = fs.readFileSync(skillMdPath, "utf-8"); + frontmatter = skillInstall.parseFrontmatter(content); + } catch (err) { + console.error(` ${err.message}`); + process.exit(1); + } + + const collected = skillInstall.collectFiles(skillDir); + if (collected.unsafePaths.length > 0) { + console.error(` Skill directory contains files with unsafe characters:`); + for (const p of collected.unsafePaths) console.error(` ${p}`); + console.error(" File names must match [A-Za-z0-9._-/]. Rename or remove them."); + process.exit(1); + } + if (collected.skippedDotfiles.length > 0) { + console.log(` ${D}Skipping ${collected.skippedDotfiles.length} dotfile(s): ${collected.skippedDotfiles.join(", ")}${R}`); + } + const fileLabel = collected.files.length === 1 ? "1 file" : `${collected.files.length} files`; + console.log(` ${G}✓${R} Validated SKILL.md (name: ${frontmatter.name}, ${fileLabel})`); + + // 2. Ensure sandbox is live + await ensureLiveSandboxOrExit(sandboxName); + + // 3. Resolve agent and paths + const agent = agentRuntime.getSessionAgent(sandboxName); + const paths = skillInstall.resolveSkillPaths(agent, frontmatter.name); + + // 4. Get SSH config + const sshConfigResult = captureOpenshell(["sandbox", "ssh-config", sandboxName], { + ignoreError: true, + }); + if (sshConfigResult.status !== 0) { + console.error(" Failed to obtain SSH configuration for the sandbox."); + process.exit(1); + } + + const tmpSshConfig = path.join(os.tmpdir(), `nemoclaw-ssh-skill-${process.pid}-${Date.now()}.conf`); + fs.writeFileSync(tmpSshConfig, sshConfigResult.output, { mode: 0o600 }); + + try { + const ctx = { configFile: tmpSshConfig, sandboxName }; + + // 5. Check if skill already exists (update vs fresh install) + const isUpdate = skillInstall.checkExisting(ctx, paths); + + // 6. Upload skill directory + const { uploaded, failed } = skillInstall.uploadDirectory(ctx, skillDir, paths.uploadDir); + if (failed.length > 0) { + console.error(` Failed to upload ${failed.length} file(s): ${failed.join(", ")}`); + process.exit(1); + } + console.log(` ${G}✓${R} Uploaded ${uploaded} file(s) to sandbox`); + + // 7. Post-install (OpenClaw mirror + refresh, or restart hint). + // Skip session refresh on updates — the agent already knows the skill; + // clearing sessions would destroy chat history unnecessarily. + const post = skillInstall.postInstall(ctx, paths, skillDir, { skipRefresh: isUpdate }); + for (const msg of post.messages) { + if (msg.startsWith("Warning:")) { + console.error(` ${YW}${msg}${R}`); + } else { + console.log(` ${D}${msg}${R}`); + } + } + + // 8. Verify + const verified = skillInstall.verifyInstall(ctx, paths); + if (verified) { + const verb = isUpdate ? "updated" : "installed"; + console.log(` ${G}✓${R} Skill '${frontmatter.name}' ${verb}`); + } else { + console.error(` Skill uploaded but verification failed at ${paths.uploadDir}/SKILL.md`); + process.exit(1); + } + } finally { + try { + fs.unlinkSync(tmpSshConfig); + } catch { + /* ignore */ + } + } +} + function cleanupSandboxServices(sandboxName, { stopHostServices = false } = {}) { if (stopHostServices) { const { stopAll } = require("./lib/services"); @@ -1331,6 +1473,9 @@ function help() { nemoclaw logs ${D}[--follow]${R} Stream sandbox logs nemoclaw destroy Stop NIM + delete sandbox ${D}(--yes to skip prompt)${R} + ${G}Skills:${R} + nemoclaw skill install Deploy a skill directory to the sandbox + ${G}Policy Presets:${R} nemoclaw policy-add Add a network or filesystem policy preset ${D}(--dry-run to preview)${R} nemoclaw policy-list List presets ${D}(● = applied)${R} @@ -1428,6 +1573,12 @@ const [cmd, ...args] = process.argv.slice(2); } // Sandbox-scoped commands: nemoclaw + // If the registry doesn't know this name but the action is connect or skill, + // attempt recovery — the sandbox may still be live with a stale registry. + if (!registry.getSandbox(cmd) && (args[0] === "connect" || args[0] === "skill")) { + validateName(cmd, "sandbox name"); + await recoverRegistryEntries({ requestedSandboxName: cmd }); + } const sandbox = registry.getSandbox(cmd); if (sandbox) { validateName(cmd, "sandbox name"); @@ -1455,23 +1606,17 @@ const [cmd, ...args] = process.argv.slice(2); case "destroy": await sandboxDestroy(cmd, actionArgs); break; + case "skill": + await sandboxSkillInstall(cmd, actionArgs); + break; default: console.error(` Unknown action: ${action}`); - console.error(` Valid actions: connect, status, logs, policy-add, policy-list, destroy`); + console.error(` Valid actions: connect, status, logs, policy-add, policy-list, skill, destroy`); process.exit(1); } return; } - if (args[0] === "connect") { - validateName(cmd, "sandbox name"); - await recoverRegistryEntries({ requestedSandboxName: cmd }); - if (registry.getSandbox(cmd)) { - await sandboxConnect(cmd); - return; - } - } - // Unknown command — suggest console.error(` Unknown command: ${cmd}`); console.error("");