Repository navigation
Add devpod cp to copy files to and from a workspace - #41
Open
renatoathaydes wants to merge 1 commit into
Open
renatoathaydes wants to merge 1 commit into
renatoathaydes wants to merge 1 commit into
Conversation
`devpod cp SRC WORKSPACE:DEST` and `devpod cp WORKSPACE:SRC DEST` copy files and directories between the local machine and a workspace's container, with scp's WORKSPACE:PATH syntax. A relative path in the workspace is relative to the workspace folder (read from the SSH config entry that `devpod up` writes), and where things land follows the rules of `docker cp`. It copies as the workspace's user (`--user` to change), over SFTP through the same connection `devpod ssh` makes. Each file is written to a private temporary file that is renamed over the destination, so that copying a file onto itself through the mounted workspace folder doesn't truncate it, read-only files can be replaced, and private files are never readable by others. Permissions, modification times and symlinks are kept, names that the destination can't hold safely are skipped, and files are copied concurrently. The SSH server in workspaces now serves each SFTP session from a `devpod helper sftp-server` process started in the user's home directory, like OpenSSH's sftp-server. Serving it in-process with sftp.WithServerWorkingDirectory made relative symlink targets absolute, for any SFTP client. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Transfers can expose private contents through permissive temporary-file and directory creation, while unvalidated source basenames can escape the destination directory and overwrite files on Windows.
4 open issues
What changed in this PR
Adds devpod cp to the CLI for copying files and directories between the local machine and workspace containers over the existing SSH connection.
Changes:
- Adds workspace path parsing, custom workspace-folder resolution, and
--userselection. - Introduces shared SFTP copy logic with metadata preservation, symlink handling, atomic file replacement, and regression tests.
- Moves SFTP sessions into helper processes to preserve relative symlink targets, and documents the new command.
| File | Description |
|---|---|
pkg/ssh/server/sftp_handler.go |
Starts a separate SFTP helper in the user’s home directory. |
pkg/ssh/config.go |
Reads the workspace folder from SSH configuration. |
pkg/ssh/config_test.go |
Tests workspace-folder flag parsing. |
pkg/filetransfer/fs.go |
Adds local and SFTP filesystem adapters. |
pkg/filetransfer/copy.go |
Implements copy rules, traversal, metadata handling, and concurrent transfers. |
pkg/filetransfer/copy_test.go |
Tests transfer semantics and regression cases. |
go.mod |
Marks two existing dependencies as direct dependencies. |
docs/pages/developing-in-workspaces/connect-to-a-workspace.mdx |
Documents copy syntax, destination rules, and user selection. |
cmd/root.go |
Registers the cp command. |
cmd/helper/sftp_server.go |
Adds the SFTP server helper command. |
cmd/helper/helper.go |
Registers the SFTP helper. |
cmd/cp.go |
Implements command parsing, connection setup, and transfer execution. |
cmd/cp_test.go |
Tests local and workspace path parsing, including Windows paths. |
CHANGELOG.md |
Records the copy command and relative-symlink fix. |
Review effort: Balanced
| if dstExists && !dstInfo.IsDir() && (dstMustBeDir || srcInfo.IsDir()) { | ||
| return Stats{}, fmt.Errorf("%s is not a directory", dstPath) | ||
| } | ||
| target := dstPath |
| } | ||
|
|
||
| c.log.Debugf("Creating directory %s", e.dstPath) | ||
| err := c.dst.Mkdir(e.dstPath) |
| } | ||
|
|
||
| func (s SFTPFS) CreateNew(p string) (io.WriteCloser, error) { | ||
| f, err := s.Client.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_EXCL) |
| } | ||
| defer in.Close() | ||
|
|
||
| tmp := c.dst.Join(e.dstDir, "."+c.dst.Base(e.dstPath)+".devpod-cp-"+randomSuffix()) |
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Summary
Adds
devpod cp, which copies files and directories between the local machine and a workspace's container, using scp'sWORKSPACE:PATHsyntax:workspaceFolder(read from the SSH config entrydevpod upwrites, falling back to/workspaces/<id>).docker cp: into an existing directory, to the path otherwise. A destination ending in/is created (with parents) if missing, and a source ending in/.copies a directory's contents.--userchanges it, and permission errors from the container suggest--user root.devpod sshmakes (pkg/filetransferholds the copy logic, for both directions).Each file is written to a private (
0600) temporary file next to its destination, given its permissions and modification time, and renamed over the destination. With local Docker the workspace folder is usually the local project folder mounted into the container, so this keepsdevpod cp my-file.txt foo:run from that folder from truncating the file, lets read-only files (such as.git/objects) be replaced on a second copy, and keeps private files private while they're written. Symlinks are kept (and converted between\and/for Windows), names the destination can't hold safely are skipped with a warning, and files are copied 8 at a time.SFTP server fix
The SSH server in workspaces served SFTP in-process with
sftp.WithServerWorkingDirectory, whichpkg/sftp(up to the latest v1.13.12) also applies to symlink targets, so any SFTP client (sftp WORKSPACE.devpod, IDE sync) gotlink -> /home/user/a.txtfor a relativea.txt. It now serves each SFTP session from a new hiddendevpod helper sftp-serverprocess started in the user's home directory, like OpenSSH'ssftp-server. Containers get it the next time a CLI of a new version connects, as the agent is re-injected.Test plan
DEST/, skipped names, the private temporary file, and local vs. container errors (go test -race ./pkg/filetransfer/)WORKSPACE:PATHparsing (including Windows drive letters) and for reading the workspace folder from the SSH configgo buildfor windows/amd64workspaceFolder): uploads and downloads of files and directories, copying onto itself from the mounted folder, read-only tree twice, relative symlinks,0600files, nested missingDEST/, the--user roothint, a relative symlink created with plain OpenSSHsftp, and 2000 small files in ~2.5 s🤖 Generated with Claude Code