Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
ae14163
feat(openshell): route session reads over gRPC
ericksoa Jul 13, 2026
9b76662
feat(openshell): resolve gRPC gateway credentials
ericksoa Jul 13, 2026
90aadd5
feat(openshell): carry sandbox stdin over gRPC
ericksoa Jul 13, 2026
363dd80
test(openshell): keep routing fixture linear
ericksoa Jul 13, 2026
8d135fd
fix(openshell): prevent replay after gRPC dispatch
ericksoa Jul 13, 2026
2f7881d
chore(stack): carry gRPC client updates
ericksoa Jul 13, 2026
c1cfafb
chore(stack): carry gRPC client updates
ericksoa Jul 13, 2026
1490b77
chore(stack): carry sandbox routing updates
ericksoa Jul 13, 2026
1d381aa
chore(stack): carry transport scanner coverage
ericksoa Jul 14, 2026
7f21edc
fix(openshell): require literal loopback for plaintext gRPC
ericksoa Jul 14, 2026
f92273f
chore(stack): carry transport and loopback fixes
ericksoa Jul 14, 2026
092f1d3
feat(openshell): route session reads over authenticated gRPC
ericksoa Jul 14, 2026
aeac00f
chore(stack): carry authenticated session routing
ericksoa Jul 14, 2026
8d3da0c
docs(openshell): document direct session transport
ericksoa Jul 14, 2026
ffe5bbb
chore(stack): carry session routing review fixes
ericksoa Jul 14, 2026
d6b8c0d
chore(stack): carry formatted review fixture
ericksoa Jul 14, 2026
7197722
chore(stack): carry formatted review fixture
ericksoa Jul 14, 2026
8d5f43b
chore(stack): carry formatted review fixture
ericksoa Jul 14, 2026
b46ebbf
chore(stack): carry formatted review fixture
ericksoa Jul 14, 2026
f097525
chore(stack): carry current main
ericksoa Jul 14, 2026
5b6ad6e
chore(stack): carry current main
ericksoa Jul 14, 2026
203b97c
chore(stack): carry current main
ericksoa Jul 14, 2026
d1a0b05
chore(stack): carry current main
ericksoa Jul 14, 2026
48748eb
chore(stack): pair protocol with its first consumer
ericksoa Jul 14, 2026
c9936ca
chore(stack): pair protocol with its first consumer
ericksoa Jul 14, 2026
7b8df71
chore(stack): pair protocol with its first consumer
ericksoa Jul 14, 2026
6f1780c
chore(stack): pair protocol with its first consumer
ericksoa Jul 14, 2026
96ec06e
test(openshell): accept IPv6 loopback gRPC endpoints
ericksoa Jul 14, 2026
eb99320
chore(stack): carry IPv6 loopback coverage
ericksoa Jul 14, 2026
d78e103
chore(stack): carry IPv6 loopback coverage
ericksoa Jul 14, 2026
7d99dbe
chore(stack): carry IPv6 loopback coverage
ericksoa Jul 14, 2026
46986df
chore(stack): carry externally landed loopback hardening
ericksoa Jul 14, 2026
8fd2064
chore(stack): align with externally advanced base
ericksoa Jul 14, 2026
daf195e
chore(stack): align with externally advanced base
ericksoa Jul 14, 2026
64dac1e
chore(stack): align with externally advanced base
ericksoa Jul 14, 2026
19df71a
chore(stack): follow consolidated first gRPC consumer
ericksoa Jul 14, 2026
07094fc
chore(stack): follow consolidated gRPC foundation
ericksoa Jul 14, 2026
dd230d7
chore(stack): follow current main
ericksoa Jul 14, 2026
102953a
chore(stack): follow current main
ericksoa Jul 14, 2026
025be82
test(openshell): cover runtime stdin forwarding
ericksoa Jul 14, 2026
8da44a6
chore(stack): follow documented fallback boundary
ericksoa Jul 14, 2026
e00cab8
chore(stack): follow exec request validation
ericksoa Jul 14, 2026
a7d149e
chore(stack): follow formatted exec boundary
ericksoa Jul 14, 2026
6e041bd
fix(openshell): bound encoded exec requests
ericksoa Jul 14, 2026
5fec3ff
fix(openshell): reject unrepresentable exec timeouts
ericksoa Jul 14, 2026
227d150
chore(stack): follow no-replay routing fix
ericksoa Jul 14, 2026
fe17221
chore(stack): follow gateway-scoped fallback
ericksoa Jul 14, 2026
7524a51
chore(stack): follow endpoint-safe fallback
ericksoa Jul 14, 2026
d2d59ad
fix(openshell): unify output capture limits
ericksoa Jul 14, 2026
7817356
fix(openshell): cap raw CLI output before decoding
ericksoa Jul 14, 2026
aa0dbd0
test(openshell): exercise real binary output limits
ericksoa Jul 14, 2026
c6975ba
test(openshell): ignore child exit status race
ericksoa Jul 14, 2026
dad8701
refactor(openshell): remove skill SSH transport (#6796)
ericksoa Jul 20, 2026
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
12 changes: 11 additions & 1 deletion docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2068,6 +2068,11 @@ $$nemoclaw my-assistant mcp remove github [--force]

Deploy a skill directory to a running sandbox.
The command validates the `SKILL.md` frontmatter (a `name` field is required), uploads all non-dot files preserving subdirectory structure, and performs agent-specific post-install steps.
NemoClaw selects one OpenShell sandbox execution transport before it changes the skill.
It uses the named gateway's direct gRPC API for plaintext, mTLS, and OIDC gateways.
It preselects the OpenShell CLI only when Cloudflare edge-tunnel authentication requires the CLI-owned tunnel.
Any other direct-client configuration error stops the command before dispatch.
After dispatch starts, NemoClaw never retries the mutation through another transport.

```bash
$$nemoclaw my-assistant skill install ./my-skill/
Expand Down Expand Up @@ -2111,6 +2116,7 @@ For new installs, the agent session index is refreshed so the agent discovers th

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.
It uses the same preselected, fail-closed OpenShell control path as `skill install`.

<AgentOnly variant="openclaw">

Expand Down Expand Up @@ -2195,7 +2201,11 @@ Pass `-f` / `--file <agents.yaml>` to point at the manifest; `--yes` confirms th

List OpenClaw conversation sessions in the sandbox.
With no subcommand the in-sandbox CLI lists stored sessions for the configured default agent.
NemoClaw invokes `openclaw sessions` via `openshell sandbox exec` and forwards OpenClaw flags verbatim, but filters default list output so internal `nemoclaw-onboard-warmup-*` sessions created during onboarding are hidden from user-facing output.
NemoClaw invokes the read-only `openclaw sessions` command through the sandbox's named OpenShell gateway and forwards OpenClaw flags verbatim.
It prefers the direct gRPC sandbox execution API.
If gateway configuration or the gRPC transport fails before NemoClaw receives a command result, it retries this read-only list operation through the OpenShell CLI.
A completed command, including a nonzero result, is never replayed during fallback.
NemoClaw filters default list output so internal `nemoclaw-onboard-warmup-*` sessions created during onboarding are hidden from user-facing output.

```bash
$$nemoclaw my-assistant sessions
Expand Down
3 changes: 0 additions & 3 deletions scripts/checks/legacy-sandbox-transports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,6 @@ const REVIEWED_SITE_TUPLES = [
["src/lib/actions/sandbox/process-recovery.ts", "privileged-sandbox-exec", 2],
["src/lib/actions/sandbox/process-recovery.ts", "ssh-command", 1],
["src/lib/actions/sandbox/process-recovery.ts", "ssh-temp-config", 1],
["src/lib/actions/sandbox/skill-install.ts", "openshell-ssh-config", 2],
["src/lib/actions/sandbox/skill-install.ts", "ssh-temp-config", 2],
["src/lib/actions/sandbox/snapshot.ts", "docker-exec-command", 1],
["src/lib/adapters/docker/container.ts", "docker-exec-command", 1],
["src/lib/adapters/openshell/client.ts", "openshell-ssh-config", 1],
Expand All @@ -57,7 +55,6 @@ const REVIEWED_SITE_TUPLES = [
["src/lib/share-command.ts", "sshfs-command", 1],
["src/lib/shields/index.ts", "privileged-sandbox-exec", 4],
["src/lib/shields/mutable-config-repair.ts", "privileged-sandbox-exec", 2],
["src/lib/skill-remote.ts", "ssh-command", 1],
["src/lib/state/openclaw-config-restore-input.ts", "ssh-command", 1],
["src/lib/state/openclaw-plugin-restore.ts", "ssh-command", 2],
["src/lib/state/openclaw-plugin-restore.ts", "ssh-temp-config", 1],
Expand Down
131 changes: 66 additions & 65 deletions src/lib/actions/sandbox/skill-install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const captureSandboxSshConfig = vi.hoisted(() => vi.fn());
const selectOpenShellSandboxControlForMutation = vi.hoisted(() => vi.fn());
const closeControl = vi.hoisted(() => vi.fn());
const control = vi.hoisted(() => ({ exec: vi.fn() }));
const getSandbox = vi.hoisted(() => vi.fn());
const getSessionAgent = vi.hoisted(() => vi.fn());
const ensureLiveSandboxOrExit = vi.hoisted(() => vi.fn());
const skillInstall = vi.hoisted(() => ({
Expand All @@ -22,10 +25,12 @@ const skillInstall = vi.hoisted(() => ({
verifyInstall: vi.fn(),
}));

vi.mock("../../adapters/openshell/runtime", () => ({
captureSandboxSshConfig,
vi.mock("../../adapters/openshell/sandbox-control-routing", () => ({
selectOpenShellSandboxControlForMutation,
}));

vi.mock("../../state/registry", () => ({ getSandbox }));

vi.mock("../../agent/runtime", () => ({
getSessionAgent,
}));
Expand Down Expand Up @@ -57,14 +62,6 @@ function restoreExitCode(previousExitCode: typeof process.exitCode): void {
process.exitCode = previousExitCode;
}

function expectTempSshConfigCleanedUp(configFile: string): void {
const configDir = path.dirname(configFile);
expect(configDir).not.toBe(os.tmpdir());
expect(path.basename(configDir)).toMatch(/^nemoclaw-ssh-skill-/);
expect(path.basename(configFile)).toBe("ssh_config");
expect(fs.existsSync(configDir)).toBe(false);
}

describe("sandbox skill action orchestration", () => {
let previousExitCode: typeof process.exitCode;

Expand All @@ -73,43 +70,50 @@ describe("sandbox skill action orchestration", () => {
process.exitCode = undefined;
vi.clearAllMocks();

captureSandboxSshConfig.mockReturnValue({ status: 0, output: "Host openshell-alpha\n" });
selectOpenShellSandboxControlForMutation.mockReturnValue({
control,
transport: "grpc",
close: closeControl,
});
getSandbox.mockReturnValue({ gatewayName: "nemoclaw-9090", gatewayPort: 9090 });
ensureLiveSandboxOrExit.mockResolvedValue(undefined);
getSessionAgent.mockReturnValue(agent);
skillInstall.validateSkillName.mockReturnValue(true);
skillInstall.resolveSkillPaths.mockReturnValue(paths);
skillInstall.checkExisting.mockReturnValue(true);
skillInstall.removeSkill.mockReturnValue({
skillInstall.checkExisting.mockResolvedValue(true);
skillInstall.removeSkill.mockResolvedValue({
success: true,
removedUploadDir: true,
removedMirrorDir: true,
clearedSessions: true,
messages: [],
});
skillInstall.verifyRemove.mockReturnValue(true);
skillInstall.verifyRemove.mockResolvedValue(true);
skillInstall.parseFrontmatter.mockReturnValue({ name: "demo-skill" });
skillInstall.collectFiles.mockReturnValue({
files: ["SKILL.md"],
skippedDotfiles: [],
unsafePaths: [],
});
skillInstall.uploadDirectory.mockReturnValue({
skillInstall.uploadDirectory.mockResolvedValue({
uploaded: 1,
failed: [],
skippedDotfiles: [],
unsafePaths: [],
});
skillInstall.postInstall.mockReturnValue({ success: true, messages: [] });
skillInstall.verifyInstall.mockReturnValue(true);
skillInstall.postInstall.mockResolvedValue({ success: true, messages: [] });
skillInstall.verifyInstall.mockResolvedValue(true);
});

afterEach(() => {
restoreExitCode(previousExitCode);
vi.restoreAllMocks();
});

it("fails skill remove when SSH config capture fails", async () => {
captureSandboxSshConfig.mockReturnValue({ status: 1, output: "" });
it("fails skill remove before dispatch when control selection fails", async () => {
selectOpenShellSandboxControlForMutation.mockImplementation(() => {
throw new Error("invalid mTLS material");
});
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}`);
Expand All @@ -120,19 +124,16 @@ describe("sandbox skill action orchestration", () => {
);

expect(ensureLiveSandboxOrExit).toHaveBeenCalledWith("alpha");
expect(captureSandboxSshConfig).toHaveBeenCalledWith("alpha", expect.any(Object));
expect(error).toHaveBeenCalledWith(" Failed to obtain SSH configuration for the sandbox.");
expect(selectOpenShellSandboxControlForMutation).toHaveBeenCalledWith("nemoclaw-9090");
expect(error).toHaveBeenCalledWith(
" Failed to configure OpenShell sandbox execution: invalid mTLS material",
);
expect(skillInstall.checkExisting).not.toHaveBeenCalled();
expect(exit).toHaveBeenCalledWith(1);
});

it("treats unknown skill existence as fatal for remove and deletes the temp SSH config", async () => {
let tempConfig = "";
skillInstall.checkExisting.mockImplementation((ctx) => {
tempConfig = ctx.configFile;
expect(fs.existsSync(tempConfig)).toBe(true);
return null;
});
it("treats unknown skill existence as fatal for remove and closes the control", async () => {
skillInstall.checkExisting.mockResolvedValue(null);
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);

await removeSandboxSkill("alpha", { name: "demo-skill" });
Expand All @@ -143,16 +144,11 @@ describe("sandbox skill action orchestration", () => {
);
expect(skillInstall.removeSkill).not.toHaveBeenCalled();
expect(skillInstall.verifyRemove).not.toHaveBeenCalled();
expect(tempConfig).not.toBe("");
expect(fs.existsSync(tempConfig)).toBe(false);
expect(closeControl).toHaveBeenCalledOnce();
});

it("reports an absent skill for remove and deletes the temp SSH config", async () => {
let tempConfig = "";
skillInstall.checkExisting.mockImplementation((ctx) => {
tempConfig = ctx.configFile;
return false;
});
it("reports an absent skill for remove and closes the control", async () => {
skillInstall.checkExisting.mockResolvedValue(false);
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);

await removeSandboxSkill("alpha", { name: "demo-skill" });
Expand All @@ -161,14 +157,12 @@ describe("sandbox skill action orchestration", () => {
expect(error).toHaveBeenCalledWith(" Skill 'demo-skill' is not installed in sandbox 'alpha'.");
expect(skillInstall.removeSkill).not.toHaveBeenCalled();
expect(skillInstall.verifyRemove).not.toHaveBeenCalled();
expect(tempConfig).not.toBe("");
expect(fs.existsSync(tempConfig)).toBe(false);
expect(closeControl).toHaveBeenCalledOnce();
});

it("removes and verifies an existing skill, then deletes the temp SSH config", async () => {
let tempConfig = "";
skillInstall.checkExisting.mockImplementation((ctx, resolvedPaths) => {
tempConfig = ctx.configFile;
it("removes and verifies an existing skill, then closes the selected control", async () => {
skillInstall.checkExisting.mockImplementation(async (ctx, resolvedPaths) => {
expect(ctx.control).toBe(control);
expect(resolvedPaths).toBe(paths);
return true;
});
Expand All @@ -179,17 +173,13 @@ describe("sandbox skill action orchestration", () => {
expect(ensureLiveSandboxOrExit).toHaveBeenCalledWith("alpha");
expect(getSessionAgent).toHaveBeenCalledWith("alpha");
expect(skillInstall.resolveSkillPaths).toHaveBeenCalledWith(agent, "demo-skill");
expect(skillInstall.removeSkill).toHaveBeenCalledWith(
expect.objectContaining({ configFile: tempConfig, sandboxName: "alpha" }),
paths,
);
expect(skillInstall.removeSkill).toHaveBeenCalledWith({ control, sandboxName: "alpha" }, paths);
expect(skillInstall.verifyRemove).toHaveBeenCalledWith(
expect.objectContaining({ configFile: tempConfig, sandboxName: "alpha" }),
{ control, sandboxName: "alpha" },
paths,
);
expect(log).toHaveBeenCalledWith(expect.stringContaining("Skill 'demo-skill' removed"));
expect(fs.existsSync(tempConfig)).toBe(false);
expectTempSshConfigCleanedUp(tempConfig);
expect(closeControl).toHaveBeenCalledOnce();
expect(process.exitCode).toBeUndefined();
});

Expand All @@ -207,19 +197,15 @@ describe("sandbox skill action orchestration", () => {
}

expect(ensureLiveSandboxOrExit).toHaveBeenCalledWith("alpha");
expect(captureSandboxSshConfig).not.toHaveBeenCalled();
expect(selectOpenShellSandboxControlForMutation).not.toHaveBeenCalled();
expect(skillInstall.uploadDirectory).not.toHaveBeenCalled();
});

it("continues skill install when the existence probe is unknown because upload plus verify are authoritative", async () => {
it("fails skill install before upload when the existence probe is inconclusive", async () => {
const skillDir = makeSkillDir();
let tempConfig = "";
skillInstall.checkExisting.mockImplementation((ctx) => {
tempConfig = ctx.configFile;
return null;
});
skillInstall.checkExisting.mockResolvedValue(null);
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
vi.spyOn(console, "log").mockImplementation(() => undefined);

try {
await installSandboxSkill("alpha", { command: "install", path: skillDir });
Expand All @@ -228,22 +214,37 @@ describe("sandbox skill action orchestration", () => {
}

expect(error).toHaveBeenCalledWith(
expect.stringContaining(
"Warning: could not check sandbox for existing skill — treating as fresh install.",
),
" Could not check if skill 'demo-skill' exists — sandbox may be unreachable. No files were uploaded.",
);
expect(skillInstall.uploadDirectory).not.toHaveBeenCalled();
expect(skillInstall.postInstall).not.toHaveBeenCalled();
expect(skillInstall.verifyInstall).not.toHaveBeenCalled();
expect(closeControl).toHaveBeenCalledOnce();
expect(process.exitCode).toBe(1);
});

it("installs when the existence probe confirms the skill is absent", async () => {
const skillDir = makeSkillDir();
skillInstall.checkExisting.mockResolvedValue(false);
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.uploadDirectory).toHaveBeenCalledWith(
expect.objectContaining({ configFile: tempConfig, sandboxName: "alpha" }),
{ control, sandboxName: "alpha" },
skillDir,
paths.uploadDir,
);
expect(skillInstall.verifyInstall).toHaveBeenCalledWith(
expect.objectContaining({ configFile: tempConfig, sandboxName: "alpha" }),
{ control, sandboxName: "alpha" },
paths,
);
expect(log).toHaveBeenCalledWith(expect.stringContaining("Skill 'demo-skill' installed"));
expect(fs.existsSync(tempConfig)).toBe(false);
expectTempSshConfigCleanedUp(tempConfig);
expect(closeControl).toHaveBeenCalledOnce();
expect(process.exitCode).toBeUndefined();
});
});
Loading
Loading