1
0
forked from wrenn/wrenn

Add filesystem operations (list, mkdir, remove) across full stack

Plumb ListDir, MakeDir, and RemovePath through all layers:
REST API → host agent RPC → envdclient → envd. These endpoints
enable a web file browser for sandbox filesystem interaction.

New endpoints (all under requireAPIKeyOrJWT):
- POST /v1/sandboxes/{id}/files/list
- POST /v1/sandboxes/{id}/files/mkdir
- POST /v1/sandboxes/{id}/files/remove
This commit is contained in:
2026-04-10 18:05:13 +06:00
parent c1987b0bda
commit c9283cac70
8 changed files with 1258 additions and 94 deletions

236
internal/api/handlers_fs.go Normal file
View File

@ -0,0 +1,236 @@
package api
import (
"net/http"
"connectrpc.com/connect"
"github.com/go-chi/chi/v5"
"git.omukk.dev/wrenn/wrenn/internal/auth"
"git.omukk.dev/wrenn/wrenn/internal/db"
"git.omukk.dev/wrenn/wrenn/internal/id"
"git.omukk.dev/wrenn/wrenn/internal/lifecycle"
pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen"
)
type fsHandler struct {
db *db.Queries
pool *lifecycle.HostClientPool
}
func newFSHandler(db *db.Queries, pool *lifecycle.HostClientPool) *fsHandler {
return &fsHandler{db: db, pool: pool}
}
type listDirRequest struct {
Path string `json:"path"`
Depth uint32 `json:"depth"`
}
type fileEntryResponse struct {
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"`
Size int64 `json:"size"`
Mode uint32 `json:"mode"`
Permissions string `json:"permissions"`
Owner string `json:"owner"`
Group string `json:"group"`
ModifiedAt int64 `json:"modified_at"`
SymlinkTarget *string `json:"symlink_target,omitempty"`
}
type listDirResponse struct {
Entries []fileEntryResponse `json:"entries"`
}
type makeDirRequest struct {
Path string `json:"path"`
}
type makeDirResponse struct {
Entry fileEntryResponse `json:"entry"`
}
type removeRequest struct {
Path string `json:"path"`
}
// ListDir handles POST /v1/sandboxes/{id}/files/list.
func (h *fsHandler) ListDir(w http.ResponseWriter, r *http.Request) {
sandboxIDStr := chi.URLParam(r, "id")
ctx := r.Context()
ac := auth.MustFromContext(ctx)
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
return
}
sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID})
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
return
}
if sb.Status != "running" {
writeError(w, http.StatusConflict, "invalid_state", "sandbox is not running")
return
}
var req listDirRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
return
}
if req.Path == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "path is required")
return
}
agent, err := agentForHost(ctx, h.db, h.pool, sb.HostID)
if err != nil {
writeError(w, http.StatusServiceUnavailable, "host_unavailable", "sandbox host is not reachable")
return
}
resp, err := agent.ListDir(ctx, connect.NewRequest(&pb.ListDirRequest{
SandboxId: sandboxIDStr,
Path: req.Path,
Depth: req.Depth,
}))
if err != nil {
status, code, msg := agentErrToHTTP(err)
writeError(w, status, code, msg)
return
}
entries := make([]fileEntryResponse, 0, len(resp.Msg.Entries))
for _, e := range resp.Msg.Entries {
entries = append(entries, fileEntryFromPB(e))
}
writeJSON(w, http.StatusOK, listDirResponse{Entries: entries})
}
// MakeDir handles POST /v1/sandboxes/{id}/files/mkdir.
func (h *fsHandler) MakeDir(w http.ResponseWriter, r *http.Request) {
sandboxIDStr := chi.URLParam(r, "id")
ctx := r.Context()
ac := auth.MustFromContext(ctx)
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
return
}
sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID})
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
return
}
if sb.Status != "running" {
writeError(w, http.StatusConflict, "invalid_state", "sandbox is not running")
return
}
var req makeDirRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
return
}
if req.Path == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "path is required")
return
}
agent, err := agentForHost(ctx, h.db, h.pool, sb.HostID)
if err != nil {
writeError(w, http.StatusServiceUnavailable, "host_unavailable", "sandbox host is not reachable")
return
}
resp, err := agent.MakeDir(ctx, connect.NewRequest(&pb.MakeDirRequest{
SandboxId: sandboxIDStr,
Path: req.Path,
}))
if err != nil {
status, code, msg := agentErrToHTTP(err)
writeError(w, status, code, msg)
return
}
writeJSON(w, http.StatusOK, makeDirResponse{Entry: fileEntryFromPB(resp.Msg.Entry)})
}
// Remove handles POST /v1/sandboxes/{id}/files/remove.
func (h *fsHandler) Remove(w http.ResponseWriter, r *http.Request) {
sandboxIDStr := chi.URLParam(r, "id")
ctx := r.Context()
ac := auth.MustFromContext(ctx)
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
return
}
sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID})
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
return
}
if sb.Status != "running" {
writeError(w, http.StatusConflict, "invalid_state", "sandbox is not running")
return
}
var req removeRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
return
}
if req.Path == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "path is required")
return
}
agent, err := agentForHost(ctx, h.db, h.pool, sb.HostID)
if err != nil {
writeError(w, http.StatusServiceUnavailable, "host_unavailable", "sandbox host is not reachable")
return
}
if _, err := agent.RemovePath(ctx, connect.NewRequest(&pb.RemovePathRequest{
SandboxId: sandboxIDStr,
Path: req.Path,
})); err != nil {
status, code, msg := agentErrToHTTP(err)
writeError(w, status, code, msg)
return
}
w.WriteHeader(http.StatusNoContent)
}
func fileEntryFromPB(e *pb.FileEntry) fileEntryResponse {
if e == nil {
return fileEntryResponse{}
}
resp := fileEntryResponse{
Name: e.Name,
Path: e.Path,
Type: e.Type,
Size: e.Size,
Mode: e.Mode,
Permissions: e.Permissions,
Owner: e.Owner,
Group: e.Group,
ModifiedAt: e.ModifiedAt,
}
if e.SymlinkTarget != nil {
resp.SymlinkTarget = e.SymlinkTarget
}
return resp
}

View File

@ -1037,6 +1037,122 @@ paths:
schema:
$ref: "#/components/schemas/Error"
/v1/sandboxes/{id}/files/list:
parameters:
- name: id
in: path
required: true
schema:
type: string
post:
summary: List directory contents
operationId: listDir
tags: [sandboxes]
security:
- apiKeyAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ListDirRequest"
responses:
"200":
description: Directory listing
content:
application/json:
schema:
$ref: "#/components/schemas/ListDirResponse"
"404":
description: Sandbox not found
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"409":
description: Sandbox not running
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/v1/sandboxes/{id}/files/mkdir:
parameters:
- name: id
in: path
required: true
schema:
type: string
post:
summary: Create a directory
operationId: makeDir
tags: [sandboxes]
security:
- apiKeyAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/MakeDirRequest"
responses:
"200":
description: Directory created
content:
application/json:
schema:
$ref: "#/components/schemas/MakeDirResponse"
"404":
description: Sandbox not found
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"409":
description: Sandbox not running
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/v1/sandboxes/{id}/files/remove:
parameters:
- name: id
in: path
required: true
schema:
type: string
post:
summary: Remove a file or directory
operationId: removePath
tags: [sandboxes]
security:
- apiKeyAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/RemoveRequest"
responses:
"204":
description: File or directory removed
"404":
description: Sandbox not found
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"409":
description: Sandbox not running
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/v1/sandboxes/{id}/exec/stream:
parameters:
- name: id
@ -1988,6 +2104,78 @@ components:
type: string
description: Absolute file path inside the sandbox
ListDirRequest:
type: object
required: [path]
properties:
path:
type: string
description: Directory path inside the sandbox
depth:
type: integer
default: 1
description: Recursion depth (0 = non-recursive, 1 = immediate children)
ListDirResponse:
type: object
properties:
entries:
type: array
items:
$ref: "#/components/schemas/FileEntry"
FileEntry:
type: object
properties:
name:
type: string
path:
type: string
type:
type: string
enum: [file, directory, symlink]
size:
type: integer
format: int64
mode:
type: integer
permissions:
type: string
description: Human-readable permissions (e.g. "-rwxr-xr-x")
owner:
type: string
group:
type: string
modified_at:
type: integer
format: int64
description: Unix timestamp (seconds)
symlink_target:
type: string
nullable: true
MakeDirRequest:
type: object
required: [path]
properties:
path:
type: string
description: Directory path to create inside the sandbox
MakeDirResponse:
type: object
properties:
entry:
$ref: "#/components/schemas/FileEntry"
RemoveRequest:
type: object
required: [path]
properties:
path:
type: string
description: Path to remove inside the sandbox
CreateHostRequest:
type: object
required: [type]

View File

@ -60,6 +60,7 @@ func New(
execStream := newExecStreamHandler(queries, pool)
files := newFilesHandler(queries, pool)
filesStream := newFilesStreamHandler(queries, pool)
fsH := newFSHandler(queries, pool)
snapshots := newSnapshotHandler(templateSvc, queries, pool, al)
authH := newAuthHandler(queries, pgPool, jwtSecret)
oauthH := newOAuthHandler(queries, pgPool, jwtSecret, oauthRegistry, oauthRedirectURL)
@ -133,6 +134,9 @@ func New(
r.Post("/files/read", files.Download)
r.Post("/files/stream/write", filesStream.StreamUpload)
r.Post("/files/stream/read", filesStream.StreamDownload)
r.Post("/files/list", fsH.ListDir)
r.Post("/files/mkdir", fsH.MakeDir)
r.Post("/files/remove", fsH.Remove)
r.Get("/metrics", metricsH.GetMetrics)
})
})

View File

@ -282,3 +282,30 @@ func (c *Client) ListDir(ctx context.Context, path string, depth uint32) (*envdp
return resp.Msg, nil
}
// MakeDir creates a directory inside the sandbox.
func (c *Client) MakeDir(ctx context.Context, path string) (*envdpb.MakeDirResponse, error) {
req := connect.NewRequest(&envdpb.MakeDirRequest{
Path: path,
})
resp, err := c.filesystem.MakeDir(ctx, req)
if err != nil {
return nil, fmt.Errorf("make dir: %w", err)
}
return resp.Msg, nil
}
// Remove removes a file or directory inside the sandbox.
func (c *Client) Remove(ctx context.Context, path string) error {
req := connect.NewRequest(&envdpb.RemoveRequest{
Path: path,
})
if _, err := c.filesystem.Remove(ctx, req); err != nil {
return fmt.Errorf("remove: %w", err)
}
return nil
}

View File

@ -15,6 +15,7 @@ import (
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
envdpb "git.omukk.dev/wrenn/wrenn/proto/envd/gen"
pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen"
"git.omukk.dev/wrenn/wrenn/proto/hostagent/gen/hostagentv1connect"
@ -252,6 +253,69 @@ func (s *Server) ReadFile(
return connect.NewResponse(&pb.ReadFileResponse{Content: content}), nil
}
func (s *Server) ListDir(
ctx context.Context,
req *connect.Request[pb.ListDirRequest],
) (*connect.Response[pb.ListDirResponse], error) {
msg := req.Msg
client, err := s.mgr.GetClient(msg.SandboxId)
if err != nil {
return nil, connect.NewError(connect.CodeNotFound, err)
}
resp, err := client.ListDir(ctx, msg.Path, msg.Depth)
if err != nil {
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("list dir: %w", err))
}
entries := make([]*pb.FileEntry, 0, len(resp.Entries))
for _, e := range resp.Entries {
entries = append(entries, entryInfoToPB(e))
}
return connect.NewResponse(&pb.ListDirResponse{Entries: entries}), nil
}
func (s *Server) MakeDir(
ctx context.Context,
req *connect.Request[pb.MakeDirRequest],
) (*connect.Response[pb.MakeDirResponse], error) {
msg := req.Msg
client, err := s.mgr.GetClient(msg.SandboxId)
if err != nil {
return nil, connect.NewError(connect.CodeNotFound, err)
}
resp, err := client.MakeDir(ctx, msg.Path)
if err != nil {
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("make dir: %w", err))
}
return connect.NewResponse(&pb.MakeDirResponse{
Entry: entryInfoToPB(resp.Entry),
}), nil
}
func (s *Server) RemovePath(
ctx context.Context,
req *connect.Request[pb.RemovePathRequest],
) (*connect.Response[pb.RemovePathResponse], error) {
msg := req.Msg
client, err := s.mgr.GetClient(msg.SandboxId)
if err != nil {
return nil, connect.NewError(connect.CodeNotFound, err)
}
if err := client.Remove(ctx, msg.Path); err != nil {
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("remove: %w", err))
}
return connect.NewResponse(&pb.RemovePathResponse{}), nil
}
func (s *Server) ExecStream(
ctx context.Context,
req *connect.Request[pb.ExecStreamRequest],
@ -545,3 +609,43 @@ func metricPointsToPB(pts []sandbox.MetricPoint) []*pb.MetricPoint {
}
return out
}
// entryInfoToPB maps an envd EntryInfo to a hostagent FileEntry.
func entryInfoToPB(e *envdpb.EntryInfo) *pb.FileEntry {
if e == nil {
return nil
}
var fileType string
switch e.Type {
case envdpb.FileType_FILE_TYPE_FILE:
fileType = "file"
case envdpb.FileType_FILE_TYPE_DIRECTORY:
fileType = "directory"
case envdpb.FileType_FILE_TYPE_SYMLINK:
fileType = "symlink"
default:
fileType = "unknown"
}
entry := &pb.FileEntry{
Name: e.Name,
Path: e.Path,
Type: fileType,
Size: e.Size,
Mode: e.Mode,
Permissions: e.Permissions,
Owner: e.Owner,
Group: e.Group,
}
if e.ModifiedTime != nil {
entry.ModifiedAt = e.ModifiedTime.GetSeconds()
}
if e.SymlinkTarget != nil {
entry.SymlinkTarget = e.SymlinkTarget
}
return entry
}