{"record":{"id":"18d27379b39cbbf6","repo":"can1357/oh-my-pi","slug":"ssh-destination-is-a-directory-path-trailing","errorCode":null,"errorMessage":"ssh://: destination is a directory path (trailing '/'); ssh:// write requires a file path","messagePattern":"ssh://: destination is a directory path \\(trailing '/'\\); ssh:// write requires a file path","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"packages/coding-agent/src/ssh/file-transfer.ts","lineNumber":120,"sourceCode":" *    It also needs write permission on the file itself (a read-only file is\n *    refused, not silently replaced).\n *  - an existing special file (FIFO/socket/device) is refused, not replaced.\n *  - anything else (a new path, a symlink to a non-directory, a dangling symlink)\n *    is committed with an atomic rename, which REPLACES a symlink with a regular\n *    file rather than writing through it (resolving the link target is not\n *    portable across the macOS/Linux hosts this stack supports).\n * Throws `ptree.NonZeroExitError` when the remote path is unwritable or the host\n * is unreachable.\n */\nexport async function writeRemoteFile(\n\ttarget: SSHConnectionTarget,\n\tremotePath: string,\n\tcontent: Uint8Array,\n\topts: RemoteFileWriteOptions,\n): Promise<void> {\n\tconst shell = await ensurePosixRemote(target);\n\tif (remotePath.endsWith(\"/\")) {\n\t\tthrow new Error(\"ssh://: destination is a directory path (trailing '/'); ssh:// write requires a file path\");\n\t}\n\tconst dest = quotePosixPath(remotePath);\n\tconst tmp = quotePosixPath(`${remotePath}.omp-tmp.${crypto.randomUUID()}`);\n\t// Stage stdin into the temp first (so the remote never blocks on an unread\n\t// pipe and a dropped connection lands in the temp, never the destination).\n\t// An EXIT trap removes the staged temp on every exit path (staging failure,\n\t// in-place success, refuse branches, or a failed rename). Commit by\n\t// destination kind: a directory (or symlink to one; `-d` follows links) is\n\t// refused; an existing non-symlink regular file is rewritten IN PLACE\n\t// (preserving inode, permission bits, ACLs, xattrs, hardlinks; setuid/setgid\n\t// may clear); an existing special file (FIFO/socket/device) is refused;\n\t// anything else (a new path or a symlink to a non-directory) uses temp+rename,\n\t// replacing such a symlink rather than writing through it.\n\tconst command =\n\t\t`t=${tmp}; trap 'rm -f -- \"$t\"' 0; ` +\n\t\t`mkdir -p -- \"$(dirname \"$t\")\" && ` +\n\t\t`cat > \"$t\" && { ` +\n\t\t`if [ -d ${dest} ]; then echo 'ssh://: destination is a directory' >&2; exit 1; ` +","sourceCodeStart":102,"sourceCodeEnd":138,"githubUrl":"https://github.com/can1357/oh-my-pi/blob/969062200754ea02cfac922e5ebb8c608c079e15/packages/coding-agent/src/ssh/file-transfer.ts#L102-L138","documentation":"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.","triggerScenarios":"Calling ssh:// write/upload with a remotePath ending in \"/\", e.g. trying to write \"dest/\" or passing a directory-style URI segment.","commonSituations":"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.","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"],"exampleFix":"// before\nawait write(target, \"/data/notes/\", content);\n// after\nawait write(target, \"/data/notes/notes.txt\", content);","handlingStrategy":"validation","validationCode":"async function writeFileSafe(target: SSHConnectionTarget, remotePath: string, content: Uint8Array) {\n  if (remotePath.endsWith(\"/\")) throw new Error(`remotePath must be a file path, got directory: ${remotePath}`);\n  return writeRemoteFile(target, remotePath, content, {});\n}","typeGuard":null,"tryCatchPattern":"try {\n  await write(target, remotePath, data);\n} catch (err) {\n  if (err instanceof Error && err.message.includes(\"destination is a directory path\")) {\n    remotePath = remotePath.replace(/\\/+$/, \"\") + \"/file.txt\";\n    return write(target, remotePath, data);\n  }\n  throw err;\n}","preventionTips":["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"],"tags":["ssh","validation","path","api-misuse"],"backgroundTag":"invalid-remote-path","analyzedSha":"969062200754ea02cfac922e5ebb8c608c079e15","analyzedAt":"2026-08-31T10:29:35.737Z","schemaVersion":2},"datasetVersion":"2026-08-31T14:17:45.589Z"}