fix(handlers): Phase 1 stub — /agent-home root key + 501 dispatch (internal#425 RFC)
Some checks failed
CI / Canvas Deploy Reminder (pull_request) Blocked by required conditions
CI / all-required (pull_request) Blocked by required conditions
E2E API Smoke Test / E2E API Smoke Test (pull_request) Blocked by required conditions
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Blocked by required conditions
Harness Replays / Harness Replays (pull_request) Blocked by required conditions
Runtime PR-Built Compatibility / detect-changes (pull_request) Waiting to run
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 17s
Harness Replays / detect-changes (pull_request) Successful in 24s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 16s
CI / Detect changes (pull_request) Successful in 1m6s
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m0s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
Handlers Postgres Integration / detect-changes (pull_request) Successful in 56s
MCP Stdio Transport Regression / MCP stdio with regular-file stdout (pull_request) Successful in 1m39s
gate-check-v3 / gate-check (pull_request) Successful in 28s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 32s
qa-review / approved (pull_request) Successful in 33s
security-review / approved (pull_request) Successful in 29s
publish-runtime-autobump / pr-validate (pull_request) Successful in 1m8s
sop-tier-check / tier-check (pull_request) Successful in 27s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m43s
sop-checklist / all-items-acked (pull_request) Successful in 45s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m53s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 2m36s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 2m46s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 3m42s
CI / Platform (Go) (pull_request) Failing after 16m59s
CI / Shellcheck (E2E scripts) (pull_request) Has been cancelled
CI / Python Lint & Test (pull_request) Has been cancelled
CI / Canvas (Next.js) (pull_request) Successful in 20m1s

Phase 1 stub for the Files API /agent-home root (internal#425 RFC).

Changes:
- allowedRoots gains "/agent-home": true (templates.go)
- isAgentHomeStubRequest helper short-circuits all four verbs
  (ListFiles/ReadFile/WriteFile/DeleteFile) with 501 + canonical
  JSON body BEFORE the DB workspace lookup — prevents "workspace not
  found" log noise from canvas probe requests
- Error messages updated to list /agent-home as a known root key
- New test file: template_files_agent_home_stub_test.go — 5 tests:
  body-is-canonical, ListFiles 501, ReadFile 501, WriteFile 501,
  DeleteFile 501, allowedRoots pinned. sqlmock used so DB is never
  consulted for stub requests (verifies pre-DB short-circuit).

Phase 2b (docker-exec backend) implements the actual file operations.

Closes #1247.
This commit is contained in:
Molecule AI · core-be 2026-05-15 23:08:16 +00:00
parent 7533265b99
commit d6fbc3726b
2 changed files with 231 additions and 8 deletions

View File

@ -0,0 +1,187 @@
package handlers
// template_files_agent_home_stub_test.go — Phase 1 stub tests for issue #1247
// (internal#425 RFC).
//
// Phase 1 stub contract:
// • allowedRoots["/agent-home"] = true (templates.go)
// • GET/POST/PUT/DELETE with ?root=/agent-home short-circuits with
// 501 + the canonical JSON error body BEFORE the DB workspace lookup.
// This prevents "workspace not found" log noise from probe requests.
// • Error message for an unknown root now mentions /agent-home as a known key.
//
// Phase 2b (not here) implements the docker-exec backend to fulfil those requests.
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
)
// canonicalAgentHomeBody is the exact 501 response body the Phase 1 stub must emit.
const canonicalAgentHomeBody = `{"error":"/agent-home not implemented yet (internal#425 RFC Phase 2b — docker-exec backend pending)"}`
// setupAgentHomeTest wires a minimal TemplatesHandler and sqlmock for
// agent-home stub tests. The DB is never consulted — the 501 fires before
// the workspace lookup — but the mock must exist so the router doesn't panic.
func setupAgentHomeTest(t *testing.T) (*TemplatesHandler, sqlmock.Sqlmock, *httptest.ResponseRecorder, *gin.Context) {
t.Helper()
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New: %v", err)
}
db.DB = mockDB
t.Cleanup(func() { db.DB = nil; mockDB.Close() })
handler := NewTemplatesHandler(t.TempDir(), nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
return handler, mock, w, c
}
// stubNotImplementedBody pinned to the exact canonical string so the test
// verifies byte-for-byte equivalence with the handler constant.
func TestAgentHomeStub_NotImplementedBodyIsCanonical(t *testing.T) {
if stubNotImplementedBody != canonicalAgentHomeBody {
t.Fatalf("stubNotImplementedBody = %q; want %q", stubNotImplementedBody, canonicalAgentHomeBody)
}
}
// TestAgentHomeStub_ListFiles_501 verifies that GET /workspaces/:id/files?root=/agent-home
// returns 501 with the canonical body and does NOT query the database.
func TestAgentHomeStub_ListFiles_501(t *testing.T) {
h, mock, w, c := setupAgentHomeTest(t)
c.Request = httptest.NewRequest("GET", "/workspaces/ws-123/files?root=/agent-home", nil)
c.Params = []gin.Param{{Key: "id", Value: "ws-123"}}
// DB must not be called — 501 fires before workspace lookup.
// We do NOT set up any mock rows; if the handler queries the DB the test
// will fail with "there is a remaining expectations which was not matched".
h.ListFiles(c)
if w.Code != http.StatusNotImplemented {
t.Errorf("ListFiles?root=/agent-home: got status %d, want 501", w.Code)
}
if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") {
t.Errorf("Content-Type = %q, want application/json", ct)
}
if body := w.Body.String(); body != canonicalAgentHomeBody {
t.Errorf("body = %q; want %q", body, canonicalAgentHomeBody)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
// TestAgentHomeStub_ReadFile_501 verifies that GET /workspaces/:id/files/path?root=/agent-home
// returns 501 and does NOT query the database.
func TestAgentHomeStub_ReadFile_501(t *testing.T) {
h, mock, w, c := setupAgentHomeTest(t)
c.Request = httptest.NewRequest("GET", "/workspaces/ws-123/files/config.yaml?root=/agent-home", nil)
c.Params = []gin.Param{{Key: "id", Value: "ws-123"}}
c.Params = append(c.Params, gin.Param{Key: "path", Value: "config.yaml"})
h.ReadFile(c)
if w.Code != http.StatusNotImplemented {
t.Errorf("ReadFile?root=/agent-home: got status %d, want 501", w.Code)
}
if body := w.Body.String(); body != canonicalAgentHomeBody {
t.Errorf("body = %q; want %q", body, canonicalAgentHomeBody)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
// TestAgentHomeStub_WriteFile_501 verifies that PUT /workspaces/:id/files/path?root=/agent-home
// returns 501 and does NOT query the database.
func TestAgentHomeStub_WriteFile_501(t *testing.T) {
h, mock, w, c := setupAgentHomeTest(t)
c.Request = httptest.NewRequest("PUT", "/workspaces/ws-123/files/config.yaml?root=/agent-home", nil)
c.Params = []gin.Param{{Key: "id", Value: "ws-123"}}
c.Params = append(c.Params, gin.Param{Key: "path", Value: "config.yaml"})
h.WriteFile(c)
if w.Code != http.StatusNotImplemented {
t.Errorf("WriteFile?root=/agent-home: got status %d, want 501", w.Code)
}
if body := w.Body.String(); body != canonicalAgentHomeBody {
t.Errorf("body = %q; want %q", body, canonicalAgentHomeBody)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
// TestAgentHomeStub_DeleteFile_501 verifies that DELETE /workspaces/:id/files/path?root=/agent-home
// returns 501 and does NOT query the database.
func TestAgentHomeStub_DeleteFile_501(t *testing.T) {
h, mock, w, c := setupAgentHomeTest(t)
c.Request = httptest.NewRequest("DELETE", "/workspaces/ws-123/files/config.yaml?root=/agent-home", nil)
c.Params = []gin.Param{{Key: "id", Value: "ws-123"}}
c.Params = append(c.Params, gin.Param{Key: "path", Value: "config.yaml"})
h.DeleteFile(c)
if w.Code != http.StatusNotImplemented {
t.Errorf("DeleteFile?root=/agent-home: got status %d, want 501", w.Code)
}
if body := w.Body.String(); body != canonicalAgentHomeBody {
t.Errorf("body = %q; want %q", body, canonicalAgentHomeBody)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
// TestAgentHomeStub_AllowedRootsPinned verifies that /agent-home is in
// allowedRoots so that the stub short-circuit is reachable.
func TestAgentHomeStub_AllowedRootsPinned(t *testing.T) {
if !allowedRoots["/agent-home"] {
t.Error("/agent-home must be in allowedRoots")
}
// Sanity-check the other roots are still present (regression guard).
for _, root := range []string{"/configs", "/workspace", "/home", "/plugins"} {
if !allowedRoots[root] {
t.Errorf("%s must still be in allowedRoots", root)
}
}
}
// TestAgentHomeStub_ListFiles_ExistingRootsUnaffected verifies that the four
// original roots still route through to the DB path (we don't wire sqlmock
// expectations here — we just confirm they return 400, not 501, for an
// invalid workspace ID).
func TestAgentHomeStub_OtherRoots_Return400OnBadWorkspace(t *testing.T) {
h, mock, w, c := setupAgentHomeTest(t)
for _, root := range []string{"/configs", "/workspace", "/home", "/plugins"} {
// The DB returns ErrNoRows → 404, not 400 or 501.
mock.ExpectQuery(`SELECT name, COALESCE`).WithArgs("bad-ws").WillReturnError(sql.ErrNoRows)
c.Request = httptest.NewRequest("GET", "/workspaces/bad-ws/files?root="+root, nil)
c.Params = []gin.Param{{Key: "id", Value: "bad-ws"}}
// Reset the response recorder between calls.
w = httptest.NewRecorder()
c.Reset()
// We can't use c.Reset() cleanly across different request/response pairs
// in a loop without re-creating the context. Just run one representative case.
if root == "/configs" {
c.Request = httptest.NewRequest("GET", "/workspaces/bad-ws/files?root="+root, nil)
c.Params = []gin.Param{{Key: "id", Value: "bad-ws"}}
w = httptest.NewRecorder()
c, _ = gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/workspaces/bad-ws/files?root="+root, nil)
c.Params = []gin.Param{{Key: "id", Value: "bad-ws"}}
h.ListFiles(c)
// 404 → workspace not found (DB returned ErrNoRows), not 501.
if w.Code == http.StatusNotImplemented {
t.Error("/configs must NOT return 501 — only /agent-home short-circuits")
}
}
}
_ = h // suppress unused
}

View File

@ -19,12 +19,22 @@ import (
// allowedRoots are the container paths that the Files API can browse.
var allowedRoots = map[string]bool{
"/configs": true,
"/workspace": true,
"/home": true,
"/plugins": true,
"/configs": true,
"/workspace": true,
"/home": true,
"/plugins": true,
"/agent-home": true, // Phase 1 stub — docker-exec backend in Phase 2b (internal#425 RFC)
}
// isAgentHomeStubRequest returns true when rootPath targets the /agent-home
// root. Phase 1 returns 501; Phase 2b implements the docker-exec backend.
func isAgentHomeStubRequest(rootPath string) bool {
return rootPath == "/agent-home"
}
// stubNotImplementedBody is the canonical 501 response body for Phase 1 stub requests.
const stubNotImplementedBody = `{"error":"/agent-home not implemented yet (internal#425 RFC Phase 2b — docker-exec backend pending)"}`
// maxUploadFiles limits the number of files in a single import/replace.
const maxUploadFiles = 200
@ -219,7 +229,15 @@ func (h *TemplatesHandler) ListFiles(c *gin.Context) {
// ?depth= — max depth to recurse (default: 1, max: 5)
rootPath := c.DefaultQuery("root", "/configs")
if !allowedRoots[rootPath] {
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins"})
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins, /agent-home"})
return
}
// Phase 1 stub — fires BEFORE the DB lookup so probe requests don't
// generate "workspace not found" log noise. Phase 2b implements the
// docker-exec backend (internal#425 RFC).
if isAgentHomeStubRequest(rootPath) {
c.Header("Content-Type", "application/json")
c.String(http.StatusNotImplemented, stubNotImplementedBody)
return
}
subPath := c.DefaultQuery("path", "")
@ -383,7 +401,13 @@ func (h *TemplatesHandler) ReadFile(c *gin.Context) {
ctx := c.Request.Context()
rootPath := c.DefaultQuery("root", "/configs")
if !allowedRoots[rootPath] {
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins"})
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins, /agent-home"})
return
}
// Phase 1 stub — fires BEFORE the DB lookup (internal#425 RFC Phase 2b).
if isAgentHomeStubRequest(rootPath) {
c.Header("Content-Type", "application/json")
c.String(http.StatusNotImplemented, stubNotImplementedBody)
return
}
@ -496,7 +520,13 @@ func (h *TemplatesHandler) WriteFile(c *gin.Context) {
ctx := c.Request.Context()
rootPath := c.DefaultQuery("root", "/configs")
if !allowedRoots[rootPath] {
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins"})
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins, /agent-home"})
return
}
// Phase 1 stub — fires BEFORE the DB lookup (internal#425 RFC Phase 2b).
if isAgentHomeStubRequest(rootPath) {
c.Header("Content-Type", "application/json")
c.String(http.StatusNotImplemented, stubNotImplementedBody)
return
}
var wsName, instanceID, runtime string
@ -573,7 +603,13 @@ func (h *TemplatesHandler) DeleteFile(c *gin.Context) {
ctx := c.Request.Context()
rootPath := c.DefaultQuery("root", "/configs")
if !allowedRoots[rootPath] {
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins"})
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins, /agent-home"})
return
}
// Phase 1 stub — fires BEFORE the DB lookup (internal#425 RFC Phase 2b).
if isAgentHomeStubRequest(rootPath) {
c.Header("Content-Type", "application/json")
c.String(http.StatusNotImplemented, stubNotImplementedBody)
return
}
var wsName, instanceID, runtime string