# Workspace/File API Hardening Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Harden the Workspace/File API foundation before adding process sessions, watcher, frontend, Git, LSP, or agents. **Architecture:** Keep the current Gin + service + workspace/fs layering. Add missing workspace listing and file stat endpoints through the service layer, enforce file write size limits in `FileService`, prevent deleting the workspace root in `LocalFS`, and strengthen HTTP tests/error response coverage. **Tech Stack:** Go 1.25, Gin, standard library filesystem APIs, existing config/logger/service layers. ## Global Constraints - Current branch starts from `main`; create a feature branch before code changes. - All application logs must use `log/slog`; do not add `fmt.Print*`, `log.Printf`, `gin.Default`, or `gin.Logger`. - Do not change existing API response shapes except where this plan explicitly adds new endpoints/responses. - Do not bypass service layer from API handlers. - Do not expose host absolute paths in API responses. - Do not log file contents, request bodies, response bodies, environment variables, tokens, secrets, or workspace root in lifecycle/request logs. - Do not implement Process Session, WebSocket watcher, frontend, Git, LSP, auth, CORS, or AI Agent features in this phase. - Code generation/refactoring must be delegated to Codex MCP using exactly `model: "kimi/kimi-k2.7-code"`, `sandbox: "danger-full-access"`, and `approval-policy: "on-failure"`. - Do not commit unless the user explicitly asks. --- ## File Structure ### Modify - `configs/config.yaml` — add `file.maxWriteBytes`. - `pkg/config/config.go` — add `FileConfig`, defaults, YAML/env loading. - `pkg/config/config_test.go` — add file config tests. - `internal/workspace/manager.go` — add `List() ([]Workspace, error)` to manager interface/local implementation. - `internal/workspace/path_test.go` or new `internal/workspace/manager_test.go` — add workspace listing tests. - `internal/fs/localfs.go` — reject `Remove(".")` / empty path after clean as workspace-root deletion. - `internal/fs/localfs_test.go` — add root delete rejection test. - `internal/model/workspace.go` — add workspace list response if needed. - `internal/model/file.go` — add stat response if needed. - `internal/service/workspace_service.go` — add `List()`. - `internal/service/file_service.go` — add max write bytes enforcement. - `internal/api/router.go` — add `GET /api/workspaces` and `GET /api/workspaces/:id/files/stat`. - `internal/api/workspace_handler.go` — add list handler. - `internal/api/file_handler.go` — add stat handler and preserve existing handlers. - `internal/api/router_test.go` — add complete workspace/file/error HTTP tests. - `cmd/server/main.go` — pass file max write bytes into `FileService`. - `README.md` — document Phase 1 API/config behavior. --- ## Task 1: Add workspace list endpoint **Files:** - Modify: `internal/workspace/manager.go` - Create or Modify: `internal/workspace/manager_test.go` - Modify: `internal/service/workspace_service.go` - Modify: `internal/model/workspace.go` - Modify: `internal/api/router.go` - Modify: `internal/api/workspace_handler.go` - Modify: `internal/api/router_test.go` **Interfaces:** - Produces: `workspace.Manager.List() ([]Workspace, error)`. - Produces: `(*service.WorkspaceService).List() ([]workspace.Workspace, error)`. - Produces: `GET /api/workspaces` returning JSON array of objects with `id` only. - [ ] **Step 1: Add failing workspace list tests** Add tests in `internal/workspace/manager_test.go`: ```go func TestLocalManagerListReturnsSortedWorkspaces(t *testing.T) { mgr := NewLocalManager(t.TempDir()) if _, err := mgr.Create("beta"); err != nil { t.Fatal(err) } if _, err := mgr.Create("alpha"); err != nil { t.Fatal(err) } got, err := mgr.List() if err != nil { t.Fatal(err) } if len(got) != 2 { t.Fatalf("len = %d, want 2", len(got)) } if got[0].ID != "alpha" || got[1].ID != "beta" { t.Fatalf("ids = %q, %q; want alpha, beta", got[0].ID, got[1].ID) } if got[0].Root == "" || got[1].Root == "" { t.Fatal("manager list should keep internal roots for service use") } } func TestLocalManagerListSkipsUnsafeDirectoryNames(t *testing.T) { root := t.TempDir() if err := os.Mkdir(filepath.Join(root, "valid"), 0o755); err != nil { t.Fatal(err) } if err := os.Mkdir(filepath.Join(root, "bad name"), 0o755); err != nil { t.Fatal(err) } mgr := NewLocalManager(root) got, err := mgr.List() if err != nil { t.Fatal(err) } if len(got) != 1 || got[0].ID != "valid" { t.Fatalf("got %#v, want only valid", got) } } ``` Imports required: `os`, `path/filepath`, `testing`. - [ ] **Step 2: Implement manager/service list** Update `internal/workspace/manager.go`: ```go type Manager interface { Create(id string) (*Workspace, error) Get(id string) (*Workspace, error) Delete(id string) error List() ([]Workspace, error) } ``` `LocalManager.List` behavior: - Ensure base workspace root exists with `os.MkdirAll(m.root, 0o755)`. - Read direct children with `os.ReadDir(m.root)`. - Include only directories whose names pass `ValidID`. - Return sorted by `ID`. - Return `[]Workspace` with internal `Root` populated. Update `WorkspaceService`: ```go func (s *WorkspaceService) List() ([]workspace.Workspace, error) { return s.workspaces.List() } ``` - [ ] **Step 3: Add model/API list response** Update `internal/model/workspace.go` if needed: ```go type WorkspaceListResponse struct { Workspaces []WorkspaceResponse `json:"workspaces"` } ``` Add route in `internal/api/router.go`: ```go api.GET("/workspaces", wsHandler.list) ``` Add handler in `internal/api/workspace_handler.go`: ```go func (h *workspaceHandler) list(c *gin.Context) { workspaces, err := h.svc.List() if err != nil { writeError(c, err); return } resp := model.WorkspaceListResponse{Workspaces: make([]model.WorkspaceResponse, 0, len(workspaces))} for _, ws := range workspaces { resp.Workspaces = append(resp.Workspaces, model.WorkspaceResponse{ID: ws.ID}) } c.JSON(http.StatusOK, resp) } ``` Do not expose `Workspace.Root`. - [ ] **Step 4: Add HTTP list test** Add to `internal/api/router_test.go`: ```go func TestListWorkspaces(t *testing.T) { router := setupTestRouter(t) for _, id := range []string{"beta", "alpha"} { payload, _ := json.Marshal(map[string]string{"id": id}) req := httptest.NewRequest("POST", "/api/workspaces", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusCreated { t.Fatalf("create %s: %d %s", id, rec.Code, rec.Body.String()) } } req := httptest.NewRequest("GET", "/api/workspaces", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } var resp struct { Workspaces []struct{ ID string `json:"id"` } `json:"workspaces"` } if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatal(err) } if len(resp.Workspaces) != 2 || resp.Workspaces[0].ID != "alpha" || resp.Workspaces[1].ID != "beta" { t.Fatalf("response = %#v", resp) } } ``` - [ ] **Step 5: Verify task** Run: ```sh gofmt -w internal/workspace internal/service internal/model internal/api go test ./internal/workspace ./internal/api ``` Expected: all tests pass. --- ## Task 2: Harden LocalFS remove and expose file stat **Files:** - Modify: `internal/fs/localfs.go` - Modify: `internal/fs/localfs_test.go` - Modify: `internal/model/file.go` - Modify: `internal/api/router.go` - Modify: `internal/api/file_handler.go` - Modify: `internal/api/router_test.go` **Interfaces:** - Produces: `LocalFS.Remove(".")` and `Remove("")` return bad request. - Produces: `GET /api/workspaces/:id/files/stat?path=...` returning file metadata. - [ ] **Step 1: Add failing root delete test** Add to `internal/fs/localfs_test.go`: ```go func TestLocalFSRejectsRemoveRoot(t *testing.T) { fsys := NewLocal(t.TempDir()) for _, path := range []string{"", "."} { if err := fsys.Remove(path); err == nil { t.Fatalf("Remove(%q) expected error", path) } } } ``` - [ ] **Step 2: Implement root delete protection** Update `LocalFS.Remove` after `resolve`: ```go if rel == "." { return util.New(util.CodeBadRequest, "cannot remove workspace root") } ``` This requires keeping the `rel` return from `resolve`. - [ ] **Step 3: Add stat response model and handler** Update `internal/model/file.go`: ```go type FileInfoResponse struct { Name string `json:"name"` Path string `json:"path"` IsDir bool `json:"isDir"` Size int64 `json:"size"` ModTime string `json:"modTime"` } ``` Add route: ```go api.GET("/workspaces/:id/files/stat", fileHandler.stat) ``` Add handler: ```go func (h *fileHandler) stat(c *gin.Context) { id := c.Param("id") path := c.Query("path") if path == "" { writeBadRequest(c, "path is required"); return } info, err := h.svc.Stat(id, path) if err != nil { writeError(c, err); return } c.JSON(http.StatusOK, model.FileInfoResponse{ Name: info.Name, Path: info.Path, IsDir: info.IsDir, Size: info.Size, ModTime: info.ModTime.Format(time.RFC3339Nano), }) } ``` Add `time` import to file handler. - [ ] **Step 4: Add HTTP stat/root delete tests** Add tests to `internal/api/router_test.go`: ```go func TestFileStat(t *testing.T) { router := setupTestRouter(t) createWorkspaceForTest(t, router, "user1") writeFileForTest(t, router, "user1", "main.go", "package main\n") req := httptest.NewRequest("GET", "/api/workspaces/user1/files/stat?path=main.go", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } var resp struct { Name string `json:"name"`; Path string `json:"path"`; IsDir bool `json:"isDir"`; Size int64 `json:"size"` } if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatal(err) } if resp.Name != "main.go" || resp.Path != "main.go" || resp.IsDir || resp.Size != int64(len("package main\n")) { t.Fatalf("resp=%#v", resp) } } func TestRemoveWorkspaceRootIsRejected(t *testing.T) { router := setupTestRouter(t) createWorkspaceForTest(t, router, "user1") req := httptest.NewRequest("DELETE", "/api/workspaces/user1/files?path=.", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } } ``` If helper functions do not exist, create these in `router_test.go`: ```go func createWorkspaceForTest(t *testing.T, router http.Handler, id string) { ... } func writeFileForTest(t *testing.T, router http.Handler, id, path, content string) { ... } ``` - [ ] **Step 5: Verify task** Run: ```sh gofmt -w internal/fs internal/model internal/api go test ./internal/fs ./internal/api ``` Expected: all tests pass. --- ## Task 3: Add max write size limit **Files:** - Modify: `configs/config.yaml` - Modify: `pkg/config/config.go` - Modify: `pkg/config/config_test.go` - Modify: `internal/service/file_service.go` - Modify: `cmd/server/main.go` - Modify: `internal/api/router_test.go` **Interfaces:** - Produces: `config.FileConfig{MaxWriteBytes int64}`. - Produces: `service.NewFileService(workspaces workspace.Manager, maxWriteBytes int64) *FileService`. - Enforces oversized writes return `400 bad_request` before hitting filesystem. - [ ] **Step 1: Add config tests** Add to `pkg/config/config_test.go`: ```go func TestDefaultIncludesFileConfig(t *testing.T) { cfg := Default() if cfg.File.MaxWriteBytes != 1048576 { t.Fatalf("File.MaxWriteBytes = %d, want 1048576", cfg.File.MaxWriteBytes) } } func TestLoadParsesFileConfigFromYAML(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.yaml") data := []byte(`file: maxWriteBytes: 128 `) if err := os.WriteFile(path, data, 0o644); err != nil { t.Fatal(err) } cfg, err := Load(path) if err != nil { t.Fatal(err) } if cfg.File.MaxWriteBytes != 128 { t.Fatalf("got %d", cfg.File.MaxWriteBytes) } } func TestLoadAppliesFileEnvironmentOverride(t *testing.T) { t.Setenv("CODESPACE_FILE_MAX_WRITE_BYTES", "256") cfg, err := Load(filepath.Join(t.TempDir(), "missing.yaml")) if err != nil { t.Fatal(err) } if cfg.File.MaxWriteBytes != 256 { t.Fatalf("got %d", cfg.File.MaxWriteBytes) } } ``` - [ ] **Step 2: Implement file config** Update `pkg/config/config.go`: ```go type Config struct { ... File FileConfig `yaml:"file"` } type FileConfig struct { MaxWriteBytes int64 `yaml:"maxWriteBytes"` } ``` Update raw config to include `File FileConfig`. Default: ```go File: FileConfig{MaxWriteBytes: 1 << 20}, ``` Raw/env handling: ```go if raw.File.MaxWriteBytes != 0 { cfg.File.MaxWriteBytes = raw.File.MaxWriteBytes } if v := os.Getenv("CODESPACE_FILE_MAX_WRITE_BYTES"); v != "" { parse int64 and assign } ``` Invalid env integer returns error. Update `configs/config.yaml`: ```yaml file: maxWriteBytes: 1048576 ``` - [ ] **Step 3: Enforce in FileService** Update `internal/service/file_service.go`: ```go type FileService struct { workspaces workspace.Manager maxWriteBytes int64 } func NewFileService(workspaces workspace.Manager, maxWriteBytes int64) *FileService { if maxWriteBytes <= 0 { maxWriteBytes = 1 << 20 } return &FileService{workspaces: workspaces, maxWriteBytes: maxWriteBytes} } ``` In `Write`: ```go if int64(len(data)) > s.maxWriteBytes { return util.New(util.CodeBadRequest, "file exceeds max write size") } ``` Do not log file content. Update call sites: ```go fileSvc := service.NewFileService(workspaces, cfg.File.MaxWriteBytes) ``` Tests can use `service.NewFileService(wsMgr, 0)` for default. - [ ] **Step 4: Add HTTP oversized write test** Add to `internal/api/router_test.go` a setup helper that accepts max write bytes: ```go func setupTestRouterWithMaxWriteBytes(t *testing.T, maxWriteBytes int64) http.Handler { ... } ``` Use it in: ```go func TestWriteFileRejectsOversizedContent(t *testing.T) { router := setupTestRouterWithMaxWriteBytes(t, 4) createWorkspaceForTest(t, router, "user1") payload, _ := json.Marshal(map[string]string{"path": "big.txt", "content": "12345"}) req := httptest.NewRequest("PUT", "/api/workspaces/user1/files/write", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } } ``` - [ ] **Step 5: Verify task** Run: ```sh gofmt -w cmd internal pkg configs go test ./pkg/config ./internal/api ./internal/service ``` Expected: all tests pass. --- ## Task 4: Complete file handler and error response tests **Files:** - Modify: `internal/api/router_test.go` **Interfaces:** - Produces HTTP tests for list/read/write/mkdir/remove/rename/stat and consistent error JSON. - [ ] **Step 1: Add full file operation test** Add or refactor `internal/api/router_test.go` to include: ```go func TestFileLifecycle(t *testing.T) { router := setupTestRouter(t) createWorkspaceForTest(t, router, "user1") mkdirPayload, _ := json.Marshal(map[string]string{"path": "dir"}) req := httptest.NewRequest("POST", "/api/workspaces/user1/files/mkdir", bytes.NewReader(mkdirPayload)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusNoContent { t.Fatalf("mkdir status=%d body=%s", rec.Code, rec.Body.String()) } writeFileForTest(t, router, "user1", "dir/old.txt", "hello") req = httptest.NewRequest("GET", "/api/workspaces/user1/files?path=dir", nil) rec = httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("list status=%d body=%s", rec.Code, rec.Body.String()) } renamePayload, _ := json.Marshal(map[string]string{"oldPath": "dir/old.txt", "newPath": "dir/new.txt"}) req = httptest.NewRequest("POST", "/api/workspaces/user1/files/rename", bytes.NewReader(renamePayload)) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusNoContent { t.Fatalf("rename status=%d body=%s", rec.Code, rec.Body.String()) } req = httptest.NewRequest("GET", "/api/workspaces/user1/files/read?path=dir/new.txt", nil) rec = httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("read status=%d body=%s", rec.Code, rec.Body.String()) } req = httptest.NewRequest("DELETE", "/api/workspaces/user1/files?path=dir/new.txt", nil) rec = httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusNoContent { t.Fatalf("remove status=%d body=%s", rec.Code, rec.Body.String()) } } ``` - [ ] **Step 2: Add consistent error JSON tests** Add helper: ```go func assertErrorResponse(t *testing.T, rec *httptest.ResponseRecorder, status int, code string) { t.Helper() if rec.Code != status { t.Fatalf("status=%d want=%d body=%s", rec.Code, status, rec.Body.String()) } var body struct { Error struct { Code string `json:"code"`; Message string `json:"message"` } `json:"error"` } if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { t.Fatal(err) } if body.Error.Code != code { t.Fatalf("code=%q want=%q", body.Error.Code, code) } if body.Error.Message == "" { t.Fatal("error message is empty") } } ``` Add tests: ```go func TestErrorResponsesAreConsistent(t *testing.T) { router := setupTestRouter(t) req := httptest.NewRequest("GET", "/api/workspaces/missing", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) assertErrorResponse(t, rec, http.StatusNotFound, "not_found") req = httptest.NewRequest("POST", "/api/workspaces", bytes.NewBufferString("{")) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() router.ServeHTTP(rec, req) assertErrorResponse(t, rec, http.StatusBadRequest, "bad_request") createWorkspaceForTest(t, router, "user1") req = httptest.NewRequest("GET", "/api/workspaces/user1/files/read?path=../secret", nil) rec = httptest.NewRecorder() router.ServeHTTP(rec, req) assertErrorResponse(t, rec, http.StatusBadRequest, "bad_request") } ``` - [ ] **Step 3: Verify task** Run: ```sh gofmt -w internal/api go test ./internal/api ``` Expected: all API tests pass. --- ## Task 5: README and final validation **Files:** - Modify: `README.md` **Interfaces:** - Produces documented workspace/file endpoints and file write limit config. - [ ] **Step 1: Update README API docs** Add documentation for: ```text GET /api/workspaces POST /api/workspaces GET /api/workspaces/:id DELETE /api/workspaces/:id GET /api/workspaces/:id/files?path=. GET /api/workspaces/:id/files/read?path=main.go GET /api/workspaces/:id/files/stat?path=main.go PUT /api/workspaces/:id/files/write POST /api/workspaces/:id/files/mkdir DELETE /api/workspaces/:id/files?path=main.go POST /api/workspaces/:id/files/rename ``` Document config: ```yaml file: maxWriteBytes: 1048576 ``` Document env override: ```text CODESPACE_FILE_MAX_WRITE_BYTES ``` Document safety rules: - File paths are workspace-relative. - Absolute paths and `..` escapes are rejected. - Deleting workspace root through file API is rejected. - File content is not logged. - [ ] **Step 2: Final validation** Run: ```sh gofmt -w cmd internal pkg go mod tidy go test ./... grep -R "log\.Printf\|log\.Println\|log\.Fatal\|fmt\.Print\|gin\.Default\|gin\.Logger" cmd internal pkg || true ``` Expected: - Tests pass. - slog-only grep only shows allowed pre-slog `log.Fatalf` in `cmd/server/main.go`, if present. - [ ] **Step 3: Server smoke test** Run: ```sh (go run ./cmd/server > /tmp/codespace-server.log 2>&1 & pid=$!; \ for i in $(seq 1 30); do \ if curl -fsS http://localhost:8080/healthz; then \ kill $pid; wait $pid 2>/dev/null || true; exit 0; \ fi; \ sleep 0.2; \ done; \ kill $pid 2>/dev/null || true; \ wait $pid 2>/dev/null || true; \ cat /tmp/codespace-server.log; exit 1) ``` Expected: ```json {"status":"ok"} ``` - [ ] **Step 4: Diff scope check** Run: ```sh git diff --stat git status --short ``` Expected: - Changes limited to workspace/file API hardening, tests, config, docs/plan, and README. - No Process Session, WebSocket watcher, frontend, Git, LSP, auth, CORS, or Agent implementation. --- ## Codex Execution Prompt Use this exact structure when dispatching Codex: ```text ## Context - Project root: /Users/taochen/llm/codespace - Current branch: feat/workspace-file-api-hardening - Go baseline: 1.25 - Existing backend uses Gin, explicit http.Server, slog logging, service layer, LocalFS. - Implementation plan: docs/superpowers/plans/2026-07-02-workspace-file-api-hardening.md ## Task Implement Phase 1 Workspace/File API hardening. Steps: 1. Add workspace list support through workspace.Manager, WorkspaceService, and GET /api/workspaces. 2. Prevent deleting workspace root through LocalFS.Remove and file API. 3. Add file stat endpoint GET /api/workspaces/:id/files/stat?path=... 4. Add file.maxWriteBytes config and enforce it in FileService.Write. 5. Add complete file handler tests and consistent error response tests. 6. Update README API/config docs. 7. Run final validation. ## Constraints - All application logs must use slog; do not add fmt.Print*, log.Printf/log.Println, gin.Default, or gin.Logger. - Do not bypass service layer from API handlers. - Do not expose host absolute paths in API responses. - Do not log file contents, request bodies, response bodies, env, tokens, secrets, or workspace root in lifecycle/request logs. - Do not implement Process Session, WebSocket watcher, frontend, Git, LSP, auth, CORS, or AI Agent features. - Do not commit. ## Acceptance - gofmt -w cmd internal pkg succeeds. - go mod tidy succeeds. - go test ./... passes. - slog-only grep has no disallowed runtime logging. - go run ./cmd/server starts and /healthz returns {"status":"ok"}. ``` --- ## Self-Review - Spec coverage: This plan covers workspace listing, file stat, root delete protection, write size config, complete file handler tests, error response tests, README docs, and validation. - Placeholder scan: No TBD/TODO/ambiguous implementation steps remain. - Type consistency: New `List`, `Stat`, `MaxWriteBytes`, and constructor signatures are consistent across manager/service/api/main/test steps. - Scope check: Plan is limited to Workspace/File API hardening. It does not implement Process Session, WebSocket watcher, frontend, Git, LSP, auth, CORS, or Agent features.