diff --git a/.agents/skills/nemoclaw-user-reference/references/commands.md b/.agents/skills/nemoclaw-user-reference/references/commands.md index 106c2656b32..57024171518 100644 --- a/.agents/skills/nemoclaw-user-reference/references/commands.md +++ b/.agents/skills/nemoclaw-user-reference/references/commands.md @@ -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 rebuild`. +- The local mount path must be on a writable filesystem; FUSE creates the mount on the host side. + If the default `~/.nemoclaw/mounts/` 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 diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 3817c9c200a..0d2bf239ccb 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -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 rebuild`. +- The local mount path must be on a writable filesystem; FUSE creates the mount on the host side. + If the default `~/.nemoclaw/mounts/` 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 diff --git a/src/lib/share-command.ts b/src/lib/share-command.ts index 38767afb2f5..2f2ec03109f 100644 --- a/src/lib/share-command.ts +++ b/src/lib/share-command.ts @@ -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; @@ -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} `, + ); + try { + fs.unlinkSync(tmpFile); + fs.rmdirSync(tmpDir); + } catch { + /* ignore */ + } + process.exit(1); + } let mountFailed = false; try { diff --git a/test/share-command-writable.test.ts b/test/share-command-writable.test.ts new file mode 100644 index 00000000000..10de65d46cd --- /dev/null +++ b/test/share-command-writable.test.ts @@ -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", + }); + }); +});