Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .agents/skills/nemoclaw-user-reference/references/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -810,6 +810,8 @@ Prerequisites:
- `sshfs` must be installed on the host (`sudo apt-get install sshfs` on Linux, `brew install macfuse && brew install sshfs` on macOS).
- The sandbox must be running.
- Sandboxes created before the `openssh-sftp-server` base image update must be rebuilt with `nemoclaw <name> rebuild`.
- The local mount path must be on a writable filesystem; FUSE creates the mount on the host side.
If the default `~/.nemoclaw/mounts/<name>` lives on a read-only filesystem, pass an explicit writable path as the second positional argument.

```console
# mount a specific path to a custom local directory
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -838,6 +838,8 @@ Prerequisites:
- `sshfs` must be installed on the host (`sudo apt-get install sshfs` on Linux, `brew install macfuse && brew install sshfs` on macOS).
- The sandbox must be running.
- Sandboxes created before the `openssh-sftp-server` base image update must be rebuilt with `nemoclaw <name> rebuild`.
- The local mount path must be on a writable filesystem; FUSE creates the mount on the host side.
If the default `~/.nemoclaw/mounts/<name>` lives on a read-only filesystem, pass an explicit writable path as the second positional argument.

```console
# mount a specific path to a custom local directory
Expand Down
46 changes: 45 additions & 1 deletion src/lib/share-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,34 @@ export function resolveLinuxUnmount(): string | null {
return null;
}

/**
* Verify that `localMount` exists and is writable so FUSE can mount onto it.
* Creates the directory (recursive) if missing, and reports the specific
* failure reason (read-only filesystem, permission denied, etc.) when the
* mount target is unusable. Returning a structured result instead of
* throwing keeps the helper unit-testable; the caller decides how to surface
* the error to the user.
*/
export function checkLocalMountWritable(localMount: string): { writable: boolean; reason?: string } {
try {
fs.mkdirSync(localMount, { recursive: true });
} catch (err: unknown) {
const code = (err as NodeJS.ErrnoException | undefined)?.code;
if (code === "EROFS") return { writable: false, reason: "parent filesystem is read-only" };
if (code === "EACCES") return { writable: false, reason: "permission denied creating the directory" };
return { writable: false, reason: err instanceof Error ? err.message : String(err) };
}
try {
fs.accessSync(localMount, fs.constants.W_OK);
} catch (err: unknown) {
const code = (err as NodeJS.ErrnoException | undefined)?.code;
if (code === "EROFS") return { writable: false, reason: "filesystem is read-only" };
if (code === "EACCES") return { writable: false, reason: "directory is not writable" };
return { writable: false, reason: err instanceof Error ? err.message : String(err) };
}
return { writable: true };
}

export type ShareMountOptions = {
sandboxName: string;
remotePath?: string;
Expand Down Expand Up @@ -126,7 +154,23 @@ export async function runShareMount(
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-sshfs-"));
const tmpFile = path.join(tmpDir, `${sandboxName}.conf`);
fs.writeFileSync(tmpFile, sshConfigResult.output, { mode: 0o600, flag: "wx" });
fs.mkdirSync(localMount, { recursive: true });

const writable = checkLocalMountWritable(localMount);
if (!writable.writable) {
console.error(` Local mount path '${localMount}' is not usable: ${writable.reason}.`);
console.error(" share mount projects sandbox files onto a host directory via SSHFS,");
console.error(" so the local target must be on a writable filesystem.");
console.error(
` Pick a writable directory: ${deps.cliName} ${sandboxName} share mount ${remotePath} <writable-path>`,
);
try {
fs.unlinkSync(tmpFile);
fs.rmdirSync(tmpDir);
} catch {
/* ignore */
}
process.exit(1);
}

let mountFailed = false;
try {
Expand Down
93 changes: 93 additions & 0 deletions test/share-command-writable.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import fs from "fs";
import { afterEach, describe, expect, it, vi } from "vitest";

import { checkLocalMountWritable } from "../dist/lib/share-command.js";

describe("checkLocalMountWritable (#3192)", () => {
afterEach(() => {
vi.restoreAllMocks();
});

it("returns writable=true when mkdirSync and accessSync both succeed", () => {
const mkdirSpy = vi.spyOn(fs, "mkdirSync").mockReturnValue(undefined);
const accessSpy = vi.spyOn(fs, "accessSync").mockImplementation(() => undefined);

const result = checkLocalMountWritable("/some/writable/path");

expect(result).toEqual({ writable: true });
expect(mkdirSpy).toHaveBeenCalledWith("/some/writable/path", { recursive: true });
expect(accessSpy).toHaveBeenCalledWith("/some/writable/path", fs.constants.W_OK);
});

it("reports a read-only filesystem when mkdirSync raises EROFS", () => {
const err = new Error("EROFS: read-only file system, mkdir '/ro/mount'") as NodeJS.ErrnoException;
err.code = "EROFS";
vi.spyOn(fs, "mkdirSync").mockImplementation(() => {
throw err;
});

expect(checkLocalMountWritable("/ro/mount")).toEqual({
writable: false,
reason: "parent filesystem is read-only",
});
});

it("reports permission denied when mkdirSync raises EACCES", () => {
const err = new Error("EACCES: permission denied, mkdir '/restricted'") as NodeJS.ErrnoException;
err.code = "EACCES";
vi.spyOn(fs, "mkdirSync").mockImplementation(() => {
throw err;
});

expect(checkLocalMountWritable("/restricted")).toEqual({
writable: false,
reason: "permission denied creating the directory",
});
});

it("falls back to the underlying error message for unexpected mkdirSync failures", () => {
const err = new Error("ENOSPC: no space left on device") as NodeJS.ErrnoException;
err.code = "ENOSPC";
vi.spyOn(fs, "mkdirSync").mockImplementation(() => {
throw err;
});

expect(checkLocalMountWritable("/full-disk")).toEqual({
writable: false,
reason: "ENOSPC: no space left on device",
});
});

it("preserves EROFS on a pre-existing directory whose filesystem is read-only", () => {
const err = new Error(
"EROFS: read-only file system, access '/preexisting/ro/mount'",
) as NodeJS.ErrnoException;
err.code = "EROFS";
vi.spyOn(fs, "mkdirSync").mockReturnValue(undefined);
vi.spyOn(fs, "accessSync").mockImplementation(() => {
throw err;
});

expect(checkLocalMountWritable("/preexisting/ro/mount")).toEqual({
writable: false,
reason: "filesystem is read-only",
});
});

it("reports a generic permission failure on EACCES from accessSync", () => {
const err = new Error("EACCES: permission denied") as NodeJS.ErrnoException;
err.code = "EACCES";
vi.spyOn(fs, "mkdirSync").mockReturnValue(undefined);
vi.spyOn(fs, "accessSync").mockImplementation(() => {
throw err;
});

expect(checkLocalMountWritable("/preexisting/no-write")).toEqual({
writable: false,
reason: "directory is not writable",
});
});
});
Loading