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
- Provide a full file path without trailing slash: "/remote/dir/file.txt"
- If you meant to write into a directory, append the desired filename
- 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
- Normalize remote paths (strip trailing slashes) before calls
- Always pass explicit filenames, never bare directories
- Build paths with a join helper that avoids double/trailing slashes
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
- Gemini Files API delete requires a valid file name
- maxAgents must be a positive integer
- Snippet file not found: ${resolved}
- Unsupported vault:// file op: ${rawOp}
- Unsupported vault:// vault op: ${rawOp}
AI-assisted analysis of can1357/oh-my-pi@9690622007 (2026-08-31).
Data as JSON: /api/errors/18d27379b39cbbf6.
Report an issue: GitHub.