Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf82c83424 | ||
|
|
81135ac818 | ||
|
|
612a57587f | ||
|
|
9429231dba | ||
|
|
1ad8dc3ae1 |
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user