From 82c6a89f6baf6b8e9fed524ec73a094eee5c6bd3 Mon Sep 17 00:00:00 2001 From: core-be Date: Fri, 15 May 2026 16:00:59 -0700 Subject: [PATCH] [stub] Files API: add /agent-home root key, 501 dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of internal#425 RFC (Files API roots — container-internal home + system/agent split). Adds the new /agent-home allowedRoots key plus short-circuit dispatch that returns 501 with the canonical pending- message body across List/Read/Write/Delete verbs. Why a stub: - Lets the canvas FilesTab design its root-selector UI against the final shape (the additional option appears in the dropdown today; the body just says "implementation pending"). - The stub-vs-real transition is server-side only — Phase 2b lands the docker-exec backend without canvas changes. - The 501 short-circuit runs BEFORE the DB lookup, so canvases that speculatively GET /agent-home don't generate workspace-not-found noise in logs. Tests: - TestAgentHomeAllowedRoot pins the allowedRoots membership. - TestAgentHomeStub_AllVerbs_Return501 pins the canonical 501 + message body across all four verbs (table-driven for symmetry). - Both assert the stub short-circuits before the DB / EIC / Docker paths, so adding the real backend doesn't have to fight a stale test that exercised a wrong layer. Existing Files API tests (ListFiles / ReadFile / WriteFile / DeleteFile / EIC dispatch / shells) still pass — diff is additive. Refs internal#425. --- .../template_files_agent_home_stub_test.go | 117 ++++++++++++++++++ .../internal/handlers/templates.go | 59 +++++++-- 2 files changed, 168 insertions(+), 8 deletions(-) create mode 100644 workspace-server/internal/handlers/template_files_agent_home_stub_test.go diff --git a/workspace-server/internal/handlers/template_files_agent_home_stub_test.go b/workspace-server/internal/handlers/template_files_agent_home_stub_test.go new file mode 100644 index 000000000..2609cc78c --- /dev/null +++ b/workspace-server/internal/handlers/template_files_agent_home_stub_test.go @@ -0,0 +1,117 @@ +package handlers + +// template_files_agent_home_stub_test.go — pins the Phase-1 stub +// contract for the /agent-home root added by internal#425 RFC. +// +// Today (pre-Phase-2b), every Files API verb against `?root=/agent-home` +// must return HTTP 501 with the canonical pending-message body. The +// stub MUST NOT: +// 1. Hit the DB (the workspace might not even exist yet from the +// canvas's POV — the root selector is testable without one). +// 2. Touch the EIC tunnel / Docker / template-dir paths — those +// would 500/404/[] depending on the env and confuse the canvas. +// 3. Accept writes/deletes that the future docker-exec backend +// would reject — fail closed. +// +// When Phase 2b lands, this file gets replaced by a real +// docker-exec dispatch test; the stub-message constant in +// templates.go disappears. + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +// TestAgentHomeAllowedRoot pins that /agent-home is in the allowedRoots +// set. Without this, a future refactor that drops the key would +// silently degrade the canvas root selector to a 400 instead of the +// stub 501. +func TestAgentHomeAllowedRoot(t *testing.T) { + if !allowedRoots["/agent-home"] { + t.Fatal("/agent-home must be in allowedRoots — RFC #425 contract") + } +} + +// TestAgentHomeStub_AllVerbs_Return501 pins the canonical stub +// response across all four verbs. Each must: +// +// - status 501 +// - body contains the canonical "/agent-home not implemented" prefix +// - NOT contain "workspace not found" (proves we short-circuit before +// the DB lookup) +// +// Driven as a table to keep symmetry — adding a fifth verb in the +// future means adding one row here. +func TestAgentHomeStub_AllVerbs_Return501(t *testing.T) { + cases := []struct { + name string + method string + invoke func(c *gin.Context) + }{ + { + name: "ListFiles", + method: "GET", + invoke: func(c *gin.Context) { (&TemplatesHandler{}).ListFiles(c) }, + }, + { + name: "ReadFile", + method: "GET", + invoke: func(c *gin.Context) { (&TemplatesHandler{}).ReadFile(c) }, + }, + { + name: "WriteFile", + method: "PUT", + invoke: func(c *gin.Context) { (&TemplatesHandler{}).WriteFile(c) }, + }, + { + name: "DeleteFile", + method: "DELETE", + invoke: func(c *gin.Context) { (&TemplatesHandler{}).DeleteFile(c) }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{ + {Key: "id", Value: "ws-stub"}, + // Path param without leading slash so DeleteFile's + // filepath.IsAbs guard doesn't 400 before the root + // dispatch runs. The List/Read/Write paths strip the + // leading slash themselves and accept either form. + {Key: "path", Value: "notes.md"}, + } + // WriteFile binds JSON; provide a minimal valid body so the + // short-circuit isn't masked by the bind-error path. + var body string + if tc.method == "PUT" { + body = `{"content":"x"}` + } + c.Request = httptest.NewRequest( + tc.method, + "/workspaces/ws-stub/files/notes.md?root=/agent-home", + strings.NewReader(body), + ) + if body != "" { + c.Request.Header.Set("Content-Type", "application/json") + } + + tc.invoke(c) + + if w.Code != http.StatusNotImplemented { + t.Fatalf("expected 501, got %d: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "/agent-home not implemented") { + t.Errorf("body should contain canonical stub message; got %s", w.Body.String()) + } + if strings.Contains(w.Body.String(), "workspace not found") { + t.Errorf("stub leaked through to DB lookup; body=%s", w.Body.String()) + } + }) + } +} diff --git a/workspace-server/internal/handlers/templates.go b/workspace-server/internal/handlers/templates.go index d51c19ccb..3db7ad40e 100644 --- a/workspace-server/internal/handlers/templates.go +++ b/workspace-server/internal/handlers/templates.go @@ -18,11 +18,35 @@ import ( ) // allowedRoots are the container paths that the Files API can browse. +// +// `/agent-home` (added 2026-05-15, internal#425 RFC) is the container's +// own $HOME — `/root` for openclaw, `/home/agent` for claude-code/hermes +// — browsed via `docker exec` rather than host-side `find`. The +// dispatch is stubbed today (returns 501); full implementation lands in +// Phase 2b of the RFC. The allowedRoots key is added now so the canvas +// can design its root-selector UI against the final shape and the +// stub-vs-full transition is server-side only. var allowedRoots = map[string]bool{ - "/configs": true, - "/workspace": true, - "/home": true, - "/plugins": true, + "/configs": true, + "/workspace": true, + "/home": true, + "/plugins": true, + "/agent-home": true, +} + +// agentHomeStubMessage is the body returned by every Files API verb +// when `?root=/agent-home` is requested before Phase 2b lands. Keep the +// status code 501 (Not Implemented) — the route exists, the verb is +// understood, but the handler is unimplemented. Distinguishes from +// 400/404 so a canvas behind a less-current server can render a clean +// "feature pending" state instead of a generic error. +const agentHomeStubMessage = "/agent-home not implemented yet (internal#425 RFC Phase 2b — docker-exec backend pending)" + +// isAgentHomeStubRequest returns true when the request targets the +// stubbed /agent-home root. Centralised so every verb in this file +// short-circuits with the same response shape. +func isAgentHomeStubRequest(rootPath string) bool { + return rootPath == "/agent-home" } // maxUploadFiles limits the number of files in a single import/replace. @@ -219,7 +243,14 @@ 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 + } + // /agent-home dispatch is stubbed pre-Phase-2b. Short-circuit before + // the DB lookup + EIC dance so a canvas exercising the new root key + // gets a clean 501 instead of a half-effort response. + if isAgentHomeStubRequest(rootPath) { + c.JSON(http.StatusNotImplemented, gin.H{"error": agentHomeStubMessage}) return } subPath := c.DefaultQuery("path", "") @@ -383,7 +414,11 @@ 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 + } + if isAgentHomeStubRequest(rootPath) { + c.JSON(http.StatusNotImplemented, gin.H{"error": agentHomeStubMessage}) return } @@ -496,7 +531,11 @@ 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 + } + if isAgentHomeStubRequest(rootPath) { + c.JSON(http.StatusNotImplemented, gin.H{"error": agentHomeStubMessage}) return } var wsName, instanceID, runtime string @@ -573,7 +612,11 @@ 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 + } + if isAgentHomeStubRequest(rootPath) { + c.JSON(http.StatusNotImplemented, gin.H{"error": agentHomeStubMessage}) return } var wsName, instanceID, runtime string -- 2.52.0