From de92e7499f86dbfeb395da24fde68eaf26bd4725 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 10 May 2026 23:03:31 +0000 Subject: [PATCH] fix: check and enforce 0o600 on SFTP key files when mount fails, then retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When sshfs fails, check permissions on the private key and known_hosts files. If any have wrong permissions (not 0o600 — possible on Docker bind-mounts with ACL inheritance such as Synology NAS), apply chmod 600 and retry the mount once with a warning log. Add ensureFileMode helper to packages/core/src/node/fs.ts (exported from node index) and three tests for it: correct mode (no-op), wrong mode (fixes and returns true), non-existent file (no-op). Agent-Logs-Url: https://github.com/mowdep/zerobyte/sessions/d5edc5d0-fa7d-424b-a763-97601f577c92 Co-authored-by: mowdep <10937987+mowdep@users.noreply.github.com> --- apps/agent/src/volume-host/backends/sftp.ts | 27 ++++++++++++-- packages/core/src/node/__tests__/fs.test.ts | 40 ++++++++++++++++++++- packages/core/src/node/fs.ts | 18 ++++++++++ packages/core/src/node/index.ts | 2 +- 4 files changed, 83 insertions(+), 4 deletions(-) diff --git a/apps/agent/src/volume-host/backends/sftp.ts b/apps/agent/src/volume-host/backends/sftp.ts index 7940c67c..9d056cc9 100644 --- a/apps/agent/src/volume-host/backends/sftp.ts +++ b/apps/agent/src/volume-host/backends/sftp.ts @@ -4,7 +4,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { spawn } from "node:child_process"; import type { BackendConfig } from "@zerobyte/contracts/volumes"; -import { FILE_MODES, logger, writeFileWithMode } from "@zerobyte/core/node"; +import { FILE_MODES, ensureFileMode, logger, writeFileWithMode } from "@zerobyte/core/node"; import { toMessage } from "@zerobyte/core/utils"; import { OPERATION_TIMEOUT, SSH_KEYS_DIR } from "../constants"; import { getMountForPath } from "../fs"; @@ -142,7 +142,30 @@ const mount = async (config: BackendConfig, mountPath: string) => { const args = [`${config.username}@${config.host}:${config.path || ""}`, mountPath, "-o", options.join(",")]; if (config.password) args.push("-o", "password_stdin"); logger.info(`Executing sshfs: sshfs ${args.join(" ")}`); - await runSshfs(args, config.password); + + try { + await runSshfs(args, config.password); + } catch (sshfsError) { + // On some Docker bind-mount filesystems (e.g. Synology NAS) the chmod call + // inside writeFileWithMode may not have taken effect due to ACL inheritance. + // SSH refuses to use key files whose permissions are too open, so check and + // re-apply 0o600 on all sensitive files written above, then retry once. + const [keyFixed, knownHostsFixed] = await Promise.all([ + config.privateKey + ? ensureFileMode(getPrivateKeyPath(mountPath), FILE_MODES.ownerReadWrite) + : Promise.resolve(false), + config.knownHosts + ? ensureFileMode(getKnownHostsPath(mountPath), FILE_MODES.ownerReadWrite) + : Promise.resolve(false), + ]); + + if (keyFixed || knownHostsFixed) { + logger.warn("SFTP mount failed and key file permissions were incorrect; permissions have been fixed. Retrying mount..."); + await runSshfs(args, config.password); + } else { + throw sshfsError; + } + } logger.info(`SFTP volume at ${mountPath} mounted successfully.`); return { status: "mounted" as const }; diff --git a/packages/core/src/node/__tests__/fs.test.ts b/packages/core/src/node/__tests__/fs.test.ts index 79b20bbd..7519413e 100644 --- a/packages/core/src/node/__tests__/fs.test.ts +++ b/packages/core/src/node/__tests__/fs.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, test } from "vitest"; -import { FILE_MODES, writeFileWithMode } from "../fs"; +import { FILE_MODES, ensureFileMode, writeFileWithMode } from "../fs"; const tempDirectories = new Set(); @@ -15,6 +15,44 @@ afterEach(async () => { tempDirectories.clear(); }); +describe("ensureFileMode", () => { + test("returns false and leaves the file unchanged when mode is already correct", async () => { + const tempDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-ensure-file-mode-")); + tempDirectories.add(tempDirectory); + + const filePath = path.join(tempDirectory, "identity"); + await fs.writeFile(filePath, "content"); + await fs.chmod(filePath, 0o600); + + const fixed = await ensureFileMode(filePath, FILE_MODES.ownerReadWrite); + + expect(fixed).toBe(false); + expect((await fs.stat(filePath)).mode & 0o777).toBe(0o600); + }); + + test("returns true and applies the correct mode when permissions are wrong", async () => { + const tempDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-ensure-file-mode-")); + tempDirectories.add(tempDirectory); + + const filePath = path.join(tempDirectory, "identity"); + await fs.writeFile(filePath, "content"); + await fs.chmod(filePath, 0o755); + + const fixed = await ensureFileMode(filePath, FILE_MODES.ownerReadWrite); + + expect(fixed).toBe(true); + expect((await fs.stat(filePath)).mode & 0o777).toBe(0o600); + }); + + test("returns false when the file does not exist", async () => { + const tempDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-ensure-file-mode-")); + tempDirectories.add(tempDirectory); + + const fixed = await ensureFileMode(path.join(tempDirectory, "nonexistent.key"), FILE_MODES.ownerReadWrite); + expect(fixed).toBe(false); + }); +}); + describe("writeFileWithMode", () => { test("applies the requested mode when creating a new file", async () => { const tempDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-write-file-with-mode-")); diff --git a/packages/core/src/node/fs.ts b/packages/core/src/node/fs.ts index de8b9d1b..4b41cd7a 100644 --- a/packages/core/src/node/fs.ts +++ b/packages/core/src/node/fs.ts @@ -6,6 +6,24 @@ export const FILE_MODES = { type FileMode = (typeof FILE_MODES)[keyof typeof FILE_MODES]; +/** + * Checks whether an existing file has the expected mode and, if not, applies chmod. + * Returns true when the mode was corrected, false when the file already had the + * correct mode or does not exist. + */ +export const ensureFileMode = async (filePath: string, mode: FileMode): Promise => { + try { + const stat = await fs.stat(filePath); + if ((stat.mode & 0o777) !== mode) { + await fs.chmod(filePath, mode); + return true; + } + } catch { + // File does not exist or cannot be stat'd — nothing to fix. + } + return false; +}; + export const writeFileWithMode = async (filePath: string, data: string, mode: FileMode) => { // Remove any existing file first so the mode option on writeFile is always applied // on a fresh file creation (mode is ignored for existing files). This also avoids diff --git a/packages/core/src/node/index.ts b/packages/core/src/node/index.ts index 61f21366..3c05c729 100644 --- a/packages/core/src/node/index.ts +++ b/packages/core/src/node/index.ts @@ -2,4 +2,4 @@ export { safeSpawn, safeExec } from "./spawn.js"; export type { SafeSpawnParams, SafeSpawnParamsLines, SafeSpawnParamsRaw, SpawnResult } from "./spawn.js"; export { logger } from "./logger.js"; export { sanitizeSensitiveData } from "../utils/sanitize.js"; -export { FILE_MODES, writeFileWithMode } from "./fs.js"; +export { FILE_MODES, writeFileWithMode, ensureFileMode } from "./fs.js";