Files
Michael Suchacz 7c74fa3548 feat: add workspace file upload agent API (#27078)
## What

Adds a dedicated workspace-agent endpoint, `POST
/api/v0/upload-chat-file`, that streams a request body into the chat's
upload directory `~/.coder/chats/<chat-id>/files/<name>`, plus the SDK
plumbing to call it:

- `coderd/x/chatfiles`: upload-name sanitization
(`SanitizeWorkspaceUploadName`), collision suffixes (compound extensions
such as `.tar.gz` stay intact: `archive_2.tar.gz`), and the shared
`~/.coder/chats/<id>/files` path layout.
- `agent/agentfiles`: the HTTP handler. On Linux/macOS the directory
chain is opened with `openat(O_NOFOLLOW)`, so symlinked path components
are rejected. The body streams into a hidden `.<name>.upload-<random>`
temp file and is published under its final name only when complete, via
`linkat` over the `_2`, `_3`, ... collision sequence, so existing files
are never overwritten and a half-written file is never visible under its
final name. Filesystems without hard links fall back to an exclusive
placeholder plus `renameat`. Other platforms use an afero-based fallback
with the same semantics. Stale temp files older than an hour are swept
before each upload, and failures are logged with a structured warning.
- `codersdk/workspacesdk`: `AgentConn.UploadChatFile` + regenerated
`agentconnmock`.

## Why

Coder Agents chat can attach images/text/PDF to prompts, but arbitrary
files (zips, datasets, binaries) have nowhere to land where the agent's
tools can act on them. This is the agent-side foundation; the coderd
route that fronts it is stacked on top.

Design notes:

- There is deliberately no size cap; the resource profile matches
existing SSH/WebSocket proxying, and client cancellation aborts the
stream (a failed write removes the temp file, so no partial files
remain). Because the agent HTTP server uses fixed 20 s read/write
timeouts, the handler pushes both deadlines forward on every received
chunk, so a slow upload only fails after a 20 s stall. If the connection
does not support deadline changes, the agent logs one warning per
request.
- This endpoint is not gated by `CODER_AGENT_BLOCK_FILE_TRANSFER`. That
flag targets SSH-transport file tools (scp/rsync/sftp). Chat uploads are
an agent API capability like `write-file` and `edit-files`, which
already bypass the flag; the interaction is documented in the FAQ.

## How tested

- `go test ./agent/agentfiles ./coderd/x/chatfiles
./codersdk/workspacesdk` (unit + umask/symlink/collision integration
tests).
- UAT on a dogfood-style deployment (2026-09-21): uploads longer than 20
s went from 502 to 201 once the workspace agent was restarted on this
build; a coderd restart alone keeps the old agent running, so the
per-chunk deadline extension needs the agent rolled (older agents get
the 409 "Restart the workspace" from #27079).

Part of CODAGT-211. Stack: agent API (this PR) -> coderd endpoint -> UI.

> This PR was authored by Mux (AI) working on Mike's behalf.
2026-09-25 09:05:51 +02:00

70 lines
1.8 KiB
Go

package agentfiles
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/spf13/afero"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/agent/agentgit"
"github.com/coder/coder/v2/agent/usershell"
"github.com/coder/coder/v2/codersdk/workspacesdk"
)
// API exposes file-related operations performed through the agent.
type API struct {
logger slog.Logger
filesystem afero.Fs
pathStore *agentgit.PathStore
envInfo usershell.EnvInfoer
bundleFilesLimits workspacesdk.BundleFilesLimits
}
// Option configures the API.
type Option func(*API)
// WithBundleFilesLimits overrides the bundle files collection limits.
func WithBundleFilesLimits(limits workspacesdk.BundleFilesLimits) Option {
return func(api *API) {
api.bundleFilesLimits = limits
}
}
// WithEnvInfo overrides how the agent user's home directory is resolved.
func WithEnvInfo(envInfo usershell.EnvInfoer) Option {
return func(api *API) {
api.envInfo = envInfo
}
}
func NewAPI(logger slog.Logger, filesystem afero.Fs, pathStore *agentgit.PathStore, opts ...Option) *API {
api := &API{
logger: logger,
filesystem: filesystem,
pathStore: pathStore,
envInfo: usershell.SystemEnvInfo{},
bundleFilesLimits: defaultBundleFilesLimits,
}
for _, opt := range opts {
opt(api)
}
return api
}
// Routes returns the HTTP handler for file-related routes.
func (api *API) Routes() http.Handler {
r := chi.NewRouter()
r.Post("/list-directory", api.HandleLS)
r.Get("/resolve-path", api.HandleResolvePath)
r.Get("/read-file", api.HandleReadFile)
r.Get("/read-file-lines", api.HandleReadFileLines)
r.Post("/write-file", api.HandleWriteFile)
r.Post("/upload-chat-file", api.HandleUploadChatFile)
r.Post("/edit-files", api.HandleEditFiles)
r.Post("/bundle-files", api.HandleBundleFiles)
return r
}