can1357/oh-my-pi · error

ssh://: destination is a directory path (trailing '/'); ssh:

Error message

ssh://: destination is a directory path (trailing '/'); ssh:// write requires a file path

What it means

writeRemoteFile stages content to a temp file and renames it onto the destination; the destination must be a file path. A trailing slash explicitly signals a directory, so the write is rejected up front rather than corrupting or ambiguously writing into a directory.

Source

Thrown at packages/coding-agent/src/ssh/file-transfer.ts:120

 *    It also needs write permission on the file itself (a read-only file is
 *    refused, not silently replaced).
 *  - an existing special file (FIFO/socket/device) is refused, not replaced.
 *  - anything else (a new path, a symlink to a non-directory, a dangling symlink)
 *    is committed with an atomic rename, which REPLACES a symlink with a regular
 *    file rather than writing through it (resolving the link target is not
 *    portable across the macOS/Linux hosts this stack supports).
 * Throws `ptree.NonZeroExitError` when the remote path is unwritable or the host
 * is unreachable.
 */
export async function writeRemoteFile(
	target: SSHConnectionTarget,
	remotePath: string,
	content: Uint8Array,
	opts: RemoteFileWriteOptions,
): Promise<void> {
	const shell = await ensurePosixRemote(target);
	if (remotePath.endsWith("/")) {
		throw new Error("ssh://: destination is a directory path (trailing '/'); ssh:// write requires a file path");
	}
	const dest = quotePosixPath(remotePath);
	const tmp = quotePosixPath(`${remotePath}.omp-tmp.${crypto.randomUUID()}`);
	// Stage stdin into the temp first (so the remote never blocks on an unread
	// pipe and a dropped connection lands in the temp, never the destination).
	// An EXIT trap removes the staged temp on every exit path (staging failure,
	// in-place success, refuse branches, or a failed rename). Commit by
	// destination kind: a directory (or symlink to one; `-d` follows links) is
	// refused; an existing non-symlink regular file is rewritten IN PLACE
	// (preserving inode, permission bits, ACLs, xattrs, hardlinks; setuid/setgid
	// may clear); an existing special file (FIFO/socket/device) is refused;
	// anything else (a new path or a symlink to a non-directory) uses temp+rename,
	// replacing such a symlink rather than writing through it.
	const command =
		`t=${tmp}; trap 'rm -f -- "$t"' 0; ` +
		`mkdir -p -- "$(dirname "$t")" && ` +
		`cat > "$t" && { ` +
		`if [ -d ${dest} ]; then echo 'ssh://: destination is a directory' >&2; exit 1; ` +

View on GitHub (pinned to 9690622007)

Solutions

  1. Provide a full file path without trailing slash: "/remote/dir/file.txt"
  2. If you meant to write into a directory, append the desired filename
  3. Trim trailing slashes on paths before calling the write/upload API

Example fix

// before
await write(target, "/data/notes/", content);
// after
await write(target, "/data/notes/notes.txt", content);
Defensive patterns

Strategy: validation

Validate before calling

async function writeFileSafe(target: SSHConnectionTarget, remotePath: string, content: Uint8Array) {
  if (remotePath.endsWith("/")) throw new Error(`remotePath must be a file path, got directory: ${remotePath}`);
  return writeRemoteFile(target, remotePath, content, {});
}

Try / catch

try {
  await write(target, remotePath, data);
} catch (err) {
  if (err instanceof Error && err.message.includes("destination is a directory path")) {
    remotePath = remotePath.replace(/\/+$/, "") + "/file.txt";
    return write(target, remotePath, data);
  }
  throw err;
}

Prevention

When it happens

Trigger: Calling ssh:// write/upload with a remotePath ending in "/", e.g. trying to write "dest/" or passing a directory-style URI segment.

Common situations: String-building the remote path from URL joins that leave a trailing slash, intending "write into this directory" without supplying a filename, porting code from tools that accept dir destinations.

Related errors


AI-assisted analysis of can1357/oh-my-pi@9690622007 (2026-08-31). Data as JSON: /api/errors/18d27379b39cbbf6. Report an issue: GitHub.