Compare commits

...
Author SHA1 Message Date
Nicolò Boschi cf82c83424 fix(self-driving-agents): always run nemoclaw setup + rebuild sandbox for network policy 2026-04-30 13:06:15 +02:00
Nicolò Boschi 81135ac818 feat(self-driving-agents): auto-detect nemoclaw sandbox, prompt if multiple 2026-04-30 12:49:07 +02:00
Nicolò Boschi 612a57587f test(self-driving-agents): add tests for nemoclaw support, version checks, arg parsing 2026-04-29 18:21:27 +02:00
Nicolò Boschi 9429231dba fix(self-driving-agents): pass skill dir (not parent) to nemoclaw skill install 2026-04-29 18:06:17 +02:00
Nicolò Boschi 1ad8dc3ae1 feat(self-driving-agents): add nemoclaw harness support
NemoClaw runs OpenClaw inside an OpenShell sandbox. The CLI:
- Checks nemoclaw is installed and sandbox exists
- Runs hindsight-nemoclaw setup for plugin + network policy config
- Installs skill into sandbox via `nemoclaw <sandbox> skill install`
- Uses the same bank resolution from openclaw plugin config
- Adds --sandbox flag (required for nemoclaw harness)
2026-04-29 17:44:30 +02:00
2 changed files with 276 additions and 28 deletions
+182 -28
View File
@@ -324,6 +324,126 @@ async function ensurePlugin(): Promise<void> {
}
}
// ── NemoClaw plugin management ─────────────────────────
function listNemoClawSandboxes(): string[] {
try {
const out = execSync("nemoclaw list", { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
return out
.split("\n")
.filter((l) => /^\s{4}\S/.test(l) && !l.includes("model:") && !l.includes("dashboard:"))
.map((l) => l.trim().replace(/\s*\*$/, ""));
} catch {
return [];
}
}
async function detectNemoClawSandbox(): Promise<string> {
const sandboxes = listNemoClawSandboxes();
if (sandboxes.length === 0) {
p.cancel("No NemoClaw sandboxes found. Create one with: nemoclaw onboard");
process.exit(1);
}
if (sandboxes.length === 1) {
p.log.info(`Using sandbox: ${color.cyan(sandboxes[0])}`);
return sandboxes[0];
}
const selected = await p.select({
message: "Select a NemoClaw sandbox:",
options: sandboxes.map((s) => ({ value: s, label: s })),
});
if (p.isCancel(selected)) {
p.cancel("Cancelled.");
process.exit(0);
}
return selected as string;
}
async function ensureNemoClawPlugin(sandboxName: string, agentId: string): Promise<void> {
// Check nemoclaw is installed
try {
execSync("which nemoclaw", { stdio: "pipe" });
} catch {
p.cancel(
"nemoclaw not found. Install it: curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash"
);
process.exit(1);
}
// Check sandbox exists
try {
execSync(`nemoclaw ${sandboxName} status`, { stdio: "pipe" });
} catch {
p.cancel(`Sandbox '${sandboxName}' not found. Create one with: nemoclaw onboard`);
process.exit(1);
}
// NemoClaw runs OpenClaw inside a sandbox with read-only config (Landlock).
// hindsight-nemoclaw setup handles everything:
// 1. Installs the openclaw plugin
// 2. Writes plugin config to host ~/.openclaw/openclaw.json
// 3. Adds the Hindsight network policy to the sandbox
// 4. Restarts the gateway
// We always run it — it's idempotent and ensures the sandbox has the
// network policy even if the host already has the plugin configured.
const config = readOpenClawConfig();
const pc = config?.plugins?.entries?.["hindsight-openclaw"]?.config || {};
if (!pc.hindsightApiUrl || !pc.hindsightApiToken) {
// No Hindsight config at all — run interactive setup
p.log.warn("Hindsight plugin needs configuration for NemoClaw.");
try {
execSync(
`npx --yes --package @vectorize-io/hindsight-nemoclaw hindsight-nemoclaw setup --sandbox ${sandboxName}`,
{ stdio: "inherit" }
);
} catch {
p.cancel(
"Plugin setup failed. Run manually:\n npx --yes --package @vectorize-io/hindsight-nemoclaw hindsight-nemoclaw setup --sandbox " +
sandboxName
);
process.exit(1);
}
} else {
// Config exists — run non-interactive setup to ensure network policy + plugin are in place
const apiUrl = pc.hindsightApiUrl;
const apiToken = pc.hindsightApiToken;
const bankPrefix = pc.bankIdPrefix || "nemoclaw";
p.log.info(`Hindsight: ${color.cyan(`External: ${apiUrl}`)}`);
try {
execSync(
`npx --yes --package @vectorize-io/hindsight-nemoclaw hindsight-nemoclaw setup` +
` --sandbox ${sandboxName}` +
` --api-url ${apiUrl}` +
` --api-token ${apiToken}` +
` --bank-prefix ${bankPrefix}` +
` --skip-plugin-install`,
{ stdio: "inherit" }
);
} catch {
p.log.warn("Failed to apply sandbox network policy. Retain may not work.");
}
}
enableKnowledgeTools();
// Rebuild sandbox so it picks up the latest host config
p.log.info("Rebuilding sandbox to apply config...");
try {
execSync(`nemoclaw ${sandboxName} rebuild --yes`, { stdio: "inherit" });
p.log.success("Sandbox rebuilt");
} catch {
p.log.warn(
`Failed to rebuild sandbox. Run manually: nemoclaw ${sandboxName} rebuild`
);
}
}
// ── Main ────────────────────────────────────────────────
async function main() {
@@ -342,8 +462,9 @@ async function main() {
${color.cyan("./local-dir")} → local directory
${color.dim("Options:")}
${color.cyan("--harness <h>")} Required. openclaw | hermes | claude-code
${color.cyan("--harness <h>")} Required. openclaw | nemoclaw
${color.cyan("--agent <name>")} Agent name (defaults to directory name)
${color.cyan("--sandbox <name>")} NemoClaw sandbox (auto-detected if only one exists)
`);
process.exit(0);
}
@@ -358,17 +479,23 @@ async function main() {
let harness: string | undefined;
let agentName: string | undefined;
let sandbox: string | undefined;
for (let i = 0; i < restArgs.length; i++) {
if (restArgs[i] === "--harness" && restArgs[i + 1]) harness = restArgs[++i];
else if (restArgs[i] === "--agent" && restArgs[i + 1]) agentName = restArgs[++i];
else if (restArgs[i] === "--sandbox" && restArgs[i + 1]) sandbox = restArgs[++i];
}
if (!harness) {
p.cancel("--harness required (openclaw | hermes | claude-code)");
p.cancel("--harness required (openclaw | nemoclaw)");
process.exit(1);
}
if (harness === "nemoclaw" && !sandbox) {
sandbox = await detectNemoClawSandbox();
}
p.intro(color.bgCyan(color.black(` self-driving-agents `)));
// Step 0: Resolve agent directory (local or GitHub)
@@ -382,12 +509,14 @@ async function main() {
if (harness === "openclaw") {
await ensurePlugin();
enableKnowledgeTools();
} else if (harness === "nemoclaw") {
await ensureNemoClawPlugin(sandbox!, agentId);
}
// Step 2: Resolve bank + API from plugin config
const { apiUrl, bankId, apiToken } = resolveFromPlugin(agentId);
const workspaceDir = join(homedir(), ".self-driving-agents", "openclaw", agentId);
const workspaceDir = join(homedir(), ".self-driving-agents", harness, agentId);
p.log.info(
[
@@ -454,14 +583,33 @@ async function main() {
}
// Step 6: Create agent + install skill
mkdirSync(workspaceDir, { recursive: true });
if (harness === "nemoclaw") {
// NemoClaw: install skill into the sandbox via nemoclaw CLI
const tmpSkillDir = join(tmpdir(), `sda-skill-${Date.now()}`);
const tmpSkill = join(tmpSkillDir, "agent-knowledge");
mkdirSync(tmpSkill, { recursive: true });
writeFileSync(join(tmpSkill, "SKILL.md"), SKILL_MD);
try {
execSync(`nemoclaw ${sandbox} skill install ${tmpSkill}`, { stdio: "inherit" });
p.log.success("Knowledge skill installed in sandbox");
} catch (err: any) {
const stderr = err?.stderr?.toString?.()?.trim() || "";
const msg = stderr || err?.message || String(err);
p.log.warn(
`Failed to install skill: ${msg}\n Install manually:\n nemoclaw ${sandbox} skill install <skill-dir>`
);
} finally {
rmSync(tmpSkillDir, { recursive: true, force: true });
}
} else {
// OpenClaw: install skill locally + create agent
mkdirSync(workspaceDir, { recursive: true });
const skillDir = join(workspaceDir, "skills", "agent-knowledge");
mkdirSync(skillDir, { recursive: true });
writeFileSync(join(skillDir, "SKILL.md"), SKILL_MD);
p.log.success("Knowledge skill installed");
const skillDir = join(workspaceDir, "skills", "agent-knowledge");
mkdirSync(skillDir, { recursive: true });
writeFileSync(join(skillDir, "SKILL.md"), SKILL_MD);
p.log.success("Knowledge skill installed");
if (harness === "openclaw") {
try {
const listOut = execSync("openclaw agents list --json", {
encoding: "utf-8",
@@ -484,29 +632,35 @@ async function main() {
`Failed to manage agent: ${msg}\n Create manually:\n openclaw agents add ${agentId} --workspace ${workspaceDir} --non-interactive`
);
}
}
// Step 7: Patch startup
const startupFile = join(workspaceDir, "AGENTS.md");
if (existsSync(startupFile)) {
let text = readFileSync(startupFile, "utf-8");
if (!text.includes("agent-knowledge")) {
text = text.replace(
"Don't ask permission. Just do it.",
"5. Read `skills/agent-knowledge/SKILL.md` and **execute its mandatory startup sequence**\n\nDon't ask permission. Just do it."
);
writeFileSync(startupFile, text);
p.log.success("Startup patched");
// Patch startup
const startupFile = join(workspaceDir, "AGENTS.md");
if (existsSync(startupFile)) {
let text = readFileSync(startupFile, "utf-8");
if (!text.includes("agent-knowledge")) {
text = text.replace(
"Don't ask permission. Just do it.",
"5. Read `skills/agent-knowledge/SKILL.md` and **execute its mandatory startup sequence**\n\nDon't ask permission. Just do it."
);
writeFileSync(startupFile, text);
p.log.success("Startup patched");
}
}
}
p.note(
[
`${color.dim("1.")} openclaw gateway restart`,
`${color.dim("2.")} openclaw tui --session agent:${agentId}:main:session1`,
].join("\n"),
"Next steps"
);
// Next steps
const nextSteps =
harness === "nemoclaw"
? [
`${color.dim("1.")} nemoclaw ${sandbox} connect`,
`${color.dim("2.")} openclaw tui --session agent:main:main:session1`,
]
: [
`${color.dim("1.")} openclaw gateway restart`,
`${color.dim("2.")} openclaw tui --session agent:${agentId}:main:session1`,
];
p.note(nextSteps.join("\n"), "Next steps");
p.outro(color.green(`'${agentId}' is ready`));
} finally {
@@ -284,4 +284,98 @@ describe("resolveFromPluginConfig", () => {
});
expect(result.bankId).toBe("static-bank");
});
it("resolves nemoclaw-style config (external API, static bank)", () => {
const result = resolveFromPluginConfig("marketing-seo", {
hindsightApiUrl: "https://api.hindsight.vectorize.io",
hindsightApiToken: "hsk_abc",
llmProvider: "claude-code",
dynamicBankId: false,
bankIdPrefix: "my-sandbox",
});
expect(result.apiUrl).toBe("https://api.hindsight.vectorize.io");
expect(result.apiToken).toBe("hsk_abc");
// dynamicBankId=false but no bankId set, so falls through to dynamic path
// with bankIdPrefix
expect(result.bankId).toBe("my-sandbox-marketing-seo::unknown::anonymous");
});
it("resolves nemoclaw-style config with static bankId", () => {
const result = resolveFromPluginConfig("marketing-seo", {
hindsightApiUrl: "https://api.hindsight.vectorize.io",
hindsightApiToken: "hsk_abc",
dynamicBankId: false,
bankId: "my-sandbox-openclaw",
});
expect(result.bankId).toBe("my-sandbox-openclaw");
});
});
describe("versionGte", () => {
function versionGte(current: string, required: string): boolean {
const [aMaj, aMin, aPat] = current.split(".").map(Number);
const [bMaj, bMin, bPat] = required.split(".").map(Number);
if (aMaj !== bMaj) return aMaj > bMaj;
if (aMin !== bMin) return aMin > bMin;
return aPat >= bPat;
}
it("equal versions return true", () => {
expect(versionGte("0.7.2", "0.7.2")).toBe(true);
});
it("higher patch returns true", () => {
expect(versionGte("0.7.3", "0.7.2")).toBe(true);
});
it("lower patch returns false", () => {
expect(versionGte("0.7.1", "0.7.2")).toBe(false);
});
it("higher minor returns true", () => {
expect(versionGte("0.8.0", "0.7.2")).toBe(true);
});
it("higher major returns true", () => {
expect(versionGte("1.0.0", "0.7.2")).toBe(true);
});
it("lower major returns false", () => {
expect(versionGte("0.6.9", "1.0.0")).toBe(false);
});
});
describe("harness argument parsing", () => {
function parseHarness(args: string[]): { harness?: string; sandbox?: string } {
let harness: string | undefined;
let sandbox: string | undefined;
for (let i = 0; i < args.length; i++) {
if (args[i] === "--harness" && args[i + 1]) harness = args[++i];
else if (args[i] === "--sandbox" && args[i + 1]) sandbox = args[++i];
}
return { harness, sandbox };
}
it("parses openclaw harness", () => {
const { harness, sandbox } = parseHarness(["--harness", "openclaw"]);
expect(harness).toBe("openclaw");
expect(sandbox).toBeUndefined();
});
it("parses nemoclaw harness with sandbox", () => {
const { harness, sandbox } = parseHarness([
"--harness",
"nemoclaw",
"--sandbox",
"my-assistant",
]);
expect(harness).toBe("nemoclaw");
expect(sandbox).toBe("my-assistant");
});
it("nemoclaw without sandbox returns undefined sandbox", () => {
const { harness, sandbox } = parseHarness(["--harness", "nemoclaw"]);
expect(harness).toBe("nemoclaw");
expect(sandbox).toBeUndefined();
});
});