Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
4 changes: 2 additions & 2 deletions docs/manage-sandboxes/workspace-files.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,8 @@ Deep Agents Code creates them when you start the first `dcode` session.
| `/sandbox/.deepagents/.state/` | Deep Agents Code runtime state, including persisted session and MCP-related state. |
| `/sandbox/.deepagents/agent/AGENTS.md` | Global memory file for the default Deep Agents Code agent, loaded at session start. |
| `/sandbox/.deepagents/agent/memories/` | Topic-specific markdown memories that Deep Agents Code can read and update across sessions. |
| `/sandbox/.deepagents/skills/` | User-level skills available to Deep Agents Code in the sandbox. |
| `/sandbox/.deepagents/agent/skills/` | Default-agent skills created by the built-in skill creator and preserved by NemoClaw snapshots. |
| `/sandbox/.deepagents/skills/` | Legacy NemoClaw skill-upload state. Deep Agents Code does not load skills from this path, and `$$nemoclaw <name> skill install` leaves it untouched. |
| `/sandbox/.deepagents/agent/skills/` | Skills that Deep Agents Code loads at session start, including skills created by the built-in skill creator and fresh-name skills installed directly by `skill install`. Preserved by NemoClaw snapshots. |
| `/sandbox/.deepagents/.nemoclaw-mcp.json` | NemoClaw-generated managed MCP projection with OpenShell credential placeholders. NemoClaw reconstructs it from host-side registry state. |
| `/sandbox/.deepagents/.state/auth.json` | Upstream auth state. The managed launchers refuse to start when this file contains credentials. |
| `/sandbox/.deepagents/.state/chatgpt-auth.json` | Upstream ChatGPT auth state. The managed launchers refuse to start when this file exists. |
Expand Down
29 changes: 21 additions & 8 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2338,6 +2338,7 @@ To install an OpenClaw plugin, refer to [Install OpenClaw Plugins](../manage-san
For OpenClaw, the command uploads the skill to the OpenClaw state directory and mirrors it into `$HOME/.openclaw/skills/<name>` when the agent home directory differs from the state directory.
That mirror makes skills listed by `openclaw skills list` available at session startup.
If mirror creation fails, NemoClaw prints a warning so you can reinstall or inspect the home directory permissions.
OpenClaw caches skill content per session, so the command also refreshes the OpenClaw session index after every install and update to avoid stale `SKILL.md` data.

</AgentOnly>
<AgentOnly variant="hermes">
Expand All @@ -2348,8 +2349,17 @@ Hermes plugins are different from NemoClaw skills.
</AgentOnly>
<AgentOnly variant="deepagents">

For Deep Agents, the command uploads skills under `/sandbox/.deepagents/skills/<name>` and mirrors agent-facing skills under `/sandbox/.deepagents/agent/skills/<name>` when the manifest requires that view.
The managed `dcode` launchers discover those skills on the next session without accepting executable hook configuration.
For Deep Agents, the command installs a fresh skill directly into `/sandbox/.deepagents/agent/skills/<name>`, the directory Deep Agents Code loads at session start.
The command creates an archive from the selected regular files and records their paths and hashes.
It rejects symlinks and special files.
Inside the sandbox, it stages the archive and verifies the same path-and-byte manifest.
It then moves the staged directory into place only if the destination is still absent.
Because Deep Agents and its built-in skill creator also own this directory, the command refuses any name whose file, directory, or symlink already exists.
Updates are not automatic.
Inspect the existing skill and use the agent's native or manual lifecycle after confirming ownership.
The legacy `/sandbox/.deepagents/skills/<name>` path is not written or treated as ownership proof.
The managed `dcode` launchers discover newly installed skills on the next session without accepting executable hook configuration.
Installation does not enable project hooks or unmanaged MCP files.

</AgentOnly>

Expand All @@ -2358,18 +2368,20 @@ If you pass a plugin-shaped directory to `skill install`, the CLI prints a plugi

Files with names starting with `.` (dotfiles) are skipped and listed in the output.
Files with unsafe path characters are rejected to prevent shell injection.
Symlinks and other non-regular paths are rejected rather than followed or copied.

If the skill already exists on the sandbox, the command updates it in place and preserves chat history.
For new installs, the agent session index is refreshed so the agent discovers the skill on the next session.
For OpenClaw and Hermes, an existing sandbox skill is updated in place and chat history is preserved.
Deep Agents supports only fresh-name installs because its active skill directory is shared with agent-authored content.
Follow the agent-specific activation guidance above after installation.

### `$$nemoclaw <name> skill remove <skill>`

Remove an installed skill from a running sandbox by skill name.
The command validates the skill name, removes the sandbox upload directory, and refreshes the agent session index so the remaining skills are rediscovered on the next session.
The command validates the skill name before it applies the agent-specific removal behavior below.

<AgentOnly variant="openclaw">

For OpenClaw, the command also removes the OpenClaw home-directory mirror when present.
For OpenClaw, the command also removes the OpenClaw home-directory mirror when present and refreshes the OpenClaw session index.

</AgentOnly>
<AgentOnly variant="hermes">
Expand All @@ -2379,8 +2391,9 @@ Run `$$nemoclaw <name> gateway restart` if prompted so the removal takes effect.
</AgentOnly>
<AgentOnly variant="deepagents">

For Deep Agents, the command removes the uploaded skill from the managed `/sandbox/.deepagents` skill paths and refreshes the session index for future `dcode` sessions.
It does not enable project hooks or unmanaged MCP files.
For Deep Agents, automatic removal is refused before any sandbox files change.
The active `/sandbox/.deepagents/agent/skills/<name>` directory is shared with agent-authored content, so its presence alone cannot prove NemoClaw owns it.
Inspect the skill and use the agent's native or manual lifecycle after confirming ownership.

</AgentOnly>

Expand Down
181 changes: 181 additions & 0 deletions src/lib/actions/sandbox/skill-install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const skillInstall = vi.hoisted(() => ({
parseFrontmatter: vi.fn(),
collectFiles: vi.fn(),
uploadDirectory: vi.fn(),
installFreshSharedSkill: vi.fn(),
postInstall: vi.fn(),
verifyInstall: vi.fn(),
}));
Expand All @@ -39,13 +40,27 @@ vi.mock("./gateway-state", () => ({
import { installSandboxSkill, removeSandboxSkill } from "./skill-install";

const paths = {
stateDir: "/sandbox/.openclaw",
uploadDir: "/sandbox/.openclaw/skills/demo-skill",
mirrorDir: "$HOME/.openclaw/skills/demo-skill",
uploadDirSharedWithAgent: false,
sessionFile: "/sandbox/.openclaw/agents/main/sessions/sessions.json",
isOpenClaw: true,
};

const agent = { name: "openclaw", configPaths: { dir: "/sandbox/.openclaw" } };
const deepAgent = {
name: "langchain-deepagents-code",
configPaths: { dir: "/sandbox/.deepagents" },
};
const sharedPaths = {
stateDir: "/sandbox/.deepagents",
uploadDir: "/sandbox/.deepagents/agent/skills/demo-skill",
mirrorDir: null,
uploadDirSharedWithAgent: true,
sessionFile: null,
isOpenClaw: false,
};

function makeSkillDir(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-action-skill-"));
Expand Down Expand Up @@ -92,13 +107,19 @@ describe("sandbox skill action orchestration", () => {
files: ["SKILL.md"],
skippedDotfiles: [],
unsafePaths: [],
unsupportedPaths: [],
});
skillInstall.uploadDirectory.mockReturnValue({
uploaded: 1,
failed: [],
skippedDotfiles: [],
unsafePaths: [],
});
skillInstall.installFreshSharedSkill.mockReturnValue({
success: true,
uploaded: 1,
contentDigest: "a".repeat(64),
});
skillInstall.postInstall.mockReturnValue({ success: true, messages: [] });
skillInstall.verifyInstall.mockReturnValue(true);
});
Expand Down Expand Up @@ -193,6 +214,22 @@ describe("sandbox skill action orchestration", () => {
expect(process.exitCode).toBeUndefined();
});

it("refuses Deep Agents removal before obtaining SSH configuration", async () => {
getSessionAgent.mockReturnValue(deepAgent);
skillInstall.resolveSkillPaths.mockReturnValue(sharedPaths);
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);

await removeSandboxSkill("alpha", { name: "demo-skill" });

expect(process.exitCode).toBe(1);
expect(error).toHaveBeenCalledWith(
expect.stringContaining("Automatic removal is unavailable for Deep Agents skills"),
);
expect(captureSandboxSshConfig).not.toHaveBeenCalled();
expect(skillInstall.checkExisting).not.toHaveBeenCalled();
expect(skillInstall.removeSkill).not.toHaveBeenCalled();
});

it("stops skill installation at the shared gateway liveness guard (#2276)", async () => {
const skillDir = makeSkillDir();
ensureLiveSandboxOrExit.mockRejectedValueOnce(new Error("wrong gateway active"));
Expand All @@ -211,6 +248,63 @@ describe("sandbox skill action orchestration", () => {
expect(skillInstall.uploadDirectory).not.toHaveBeenCalled();
});

it("refuses a SKILL.md symlink before parsing or contacting the sandbox", async () => {
const skillDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-action-skill-link-"));
const target = path.join(skillDir, "target.md");
fs.writeFileSync(target, "---\nname: demo-skill\n---\n# Demo\n");
fs.symlinkSync(target, path.join(skillDir, "SKILL.md"));
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
const exit = vi.spyOn(process, "exit").mockImplementation(((code?: string | number | null) => {
throw new Error(`process.exit ${code}`);
}) as typeof process.exit);

try {
await expect(
installSandboxSkill("alpha", { command: "install", path: skillDir }),
).rejects.toThrow("process.exit 1");
} finally {
fs.rmSync(skillDir, { recursive: true, force: true });
}

expect(error).toHaveBeenCalledWith(expect.stringContaining("must be a regular file"));
expect(skillInstall.parseFrontmatter).not.toHaveBeenCalled();
expect(captureSandboxSshConfig).not.toHaveBeenCalled();
expect(exit).toHaveBeenCalledWith(1);
});

it("fails closed when SKILL.md is replaced between path validation and descriptor open", async () => {
const skillDir = makeSkillDir();
const skillMdPath = path.join(skillDir, "SKILL.md");
const replacement = path.join(skillDir, "replacement.md");
fs.writeFileSync(replacement, "---\nname: attacker\n---\n# Replacement\n");
let openedFlags = 0;
vi.spyOn(fs, "openSync").mockImplementationOnce((_candidatePath, flags) => {
openedFlags = flags as number;
fs.rmSync(skillMdPath);
fs.symlinkSync(replacement, skillMdPath);
throw Object.assign(new Error("symbolic link refused"), { code: "ELOOP" });
});
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
const exit = vi.spyOn(process, "exit").mockImplementation(((code?: string | number | null) => {
throw new Error(`process.exit ${code}`);
}) as typeof process.exit);

try {
await expect(
installSandboxSkill("alpha", { command: "install", path: skillDir }),
).rejects.toThrow("process.exit 1");
} finally {
fs.rmSync(skillDir, { recursive: true, force: true });
}

expect(openedFlags & fs.constants.O_NOFOLLOW).toBe(fs.constants.O_NOFOLLOW);
expect(openedFlags & fs.constants.O_NONBLOCK).toBe(fs.constants.O_NONBLOCK);
expect(error).toHaveBeenCalledWith(expect.stringContaining("must be a regular file"));
expect(skillInstall.parseFrontmatter).not.toHaveBeenCalled();
expect(captureSandboxSshConfig).not.toHaveBeenCalled();
expect(exit).toHaveBeenCalledWith(1);
});

it("continues skill install when the existence probe is unknown because upload plus verify are authoritative", async () => {
const skillDir = makeSkillDir();
let tempConfig = "";
Expand Down Expand Up @@ -247,6 +341,93 @@ describe("sandbox skill action orchestration", () => {
expect(process.exitCode).toBeUndefined();
});

it("fresh-installs and verifies Deep Agents content without generic upload mutation", async () => {
const skillDir = makeSkillDir();
getSessionAgent.mockReturnValue(deepAgent);
skillInstall.resolveSkillPaths.mockReturnValue(sharedPaths);
let tempConfig = "";
skillInstall.installFreshSharedSkill.mockImplementation((ctx) => {
tempConfig = ctx.configFile;
return { success: true, uploaded: 1, contentDigest: "a".repeat(64) };
});
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);

try {
await installSandboxSkill("alpha", { command: "install", path: skillDir });
} finally {
fs.rmSync(skillDir, { recursive: true, force: true });
}

expect(skillInstall.installFreshSharedSkill).toHaveBeenCalledWith(
expect.objectContaining({ configFile: tempConfig, sandboxName: "alpha" }),
skillDir,
sharedPaths,
);
expect(skillInstall.checkExisting).not.toHaveBeenCalled();
expect(skillInstall.uploadDirectory).not.toHaveBeenCalled();
expect(skillInstall.postInstall).not.toHaveBeenCalled();
expect(log).toHaveBeenCalledWith(expect.stringContaining("Skill 'demo-skill' installed"));
expect(log).toHaveBeenCalledWith(
expect.stringContaining("Start a new Deep Agents session to load the skill."),
);
expectTempSshConfigCleanedUp(tempConfig);
expect(process.exitCode).toBeUndefined();
});

it("refuses a colliding Deep Agents destination without generic mutation", async () => {
const skillDir = makeSkillDir();
getSessionAgent.mockReturnValue(deepAgent);
skillInstall.resolveSkillPaths.mockReturnValue(sharedPaths);
skillInstall.installFreshSharedSkill.mockReturnValue({
success: false,
uploaded: 0,
reason: "destination_exists",
});
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);

try {
await installSandboxSkill("alpha", { command: "install", path: skillDir });
} finally {
fs.rmSync(skillDir, { recursive: true, force: true });
}

expect(process.exitCode).toBe(1);
expect(error).toHaveBeenCalledWith(
expect.stringContaining("the Deep Agents skill destination already exists"),
);
expect(skillInstall.checkExisting).not.toHaveBeenCalled();
expect(skillInstall.uploadDirectory).not.toHaveBeenCalled();
expect(skillInstall.postInstall).not.toHaveBeenCalled();
});

it("reports unknown Deep Agents commit state with inspect-before-retry guidance", async () => {
const skillDir = makeSkillDir();
getSessionAgent.mockReturnValue(deepAgent);
skillInstall.resolveSkillPaths.mockReturnValue(sharedPaths);
skillInstall.installFreshSharedSkill.mockReturnValue({
success: false,
uploaded: 0,
reason: "remote_state_unknown",
});
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);

try {
await installSandboxSkill("alpha", { command: "install", path: skillDir });
} finally {
fs.rmSync(skillDir, { recursive: true, force: true });
}

const output = error.mock.calls.map((args) => args.join(" ")).join("\n");
expect(process.exitCode).toBe(1);
expect(output).toContain("did not confirm whether the Deep Agents skill was committed");
expect(output).toContain(
"Inspect /sandbox/.deepagents/agent/skills/demo-skill before retrying",
);
expect(skillInstall.checkExisting).not.toHaveBeenCalled();
expect(skillInstall.uploadDirectory).not.toHaveBeenCalled();
expect(skillInstall.postInstall).not.toHaveBeenCalled();
});

it("adds shields recovery guidance when skill upload fails (#6859)", async () => {
const skillDir = makeSkillDir();
skillInstall.uploadDirectory.mockReturnValue({
Expand Down
Loading
Loading