This repository has been archived on 2026-07-17. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
codespace/docs/superpowers/plans/2026-07-02-workspace-file-api-hardening.md
2026-07-02 17:22:26 +08:00

23 KiB

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:

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:

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:

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:

type WorkspaceListResponse struct {
    Workspaces []WorkspaceResponse `json:"workspaces"`
}

Add route in internal/api/router.go:

api.GET("/workspaces", wsHandler.list)

Add handler in internal/api/workspace_handler.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:

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:

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:

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:

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:

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:

api.GET("/workspaces/:id/files/stat", fileHandler.stat)

Add handler:

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:

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:

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:

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:

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:

type Config struct { ... File FileConfig `yaml:"file"` }
type FileConfig struct { MaxWriteBytes int64 `yaml:"maxWriteBytes"` }

Update raw config to include File FileConfig.

Default:

File: FileConfig{MaxWriteBytes: 1 << 20},

Raw/env handling:

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:

file:
  maxWriteBytes: 1048576
  • Step 3: Enforce in FileService

Update internal/service/file_service.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:

if int64(len(data)) > s.maxWriteBytes {
    return util.New(util.CodeBadRequest, "file exceeds max write size")
}

Do not log file content.

Update call sites:

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:

func setupTestRouterWithMaxWriteBytes(t *testing.T, maxWriteBytes int64) http.Handler { ... }

Use it in:

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:

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:

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:

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:

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:

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:

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:

file:
  maxWriteBytes: 1048576

Document env override:

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:

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:

(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:

{"status":"ok"}
  • Step 4: Diff scope check

Run:

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:

## 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.