diff --git a/platform/internal/handlers/registry.go b/platform/internal/handlers/registry.go index bb4aecfb..445d6903 100644 --- a/platform/internal/handlers/registry.go +++ b/platform/internal/handlers/registry.go @@ -144,7 +144,7 @@ func (h *RegistryHandler) Register(c *gin.Context) { `, payload.ID, payload.ID, payload.URL, agentCardStr) if err != nil { log.Printf("Registry register error: %v (id=%s)", err, payload.ID) - c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to register: %v", err)}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "registration failed"}) return } diff --git a/platform/internal/handlers/registry_test.go b/platform/internal/handlers/registry_test.go index d38e3679..2420c3ed 100644 --- a/platform/internal/handlers/registry_test.go +++ b/platform/internal/handlers/registry_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "github.com/DATA-DOG/go-sqlmock" @@ -597,3 +598,59 @@ func TestRegister_C18_HijackBlockedNoBearer(t *testing.T) { t.Errorf("C18 hijack: unmet expectations: %v", err) } } + +// ==================== Issue #435 — DB error must not leak raw message ==================== + +// TestRegister_DBErrorResponseIsOpaque verifies that when the DB upsert fails, +// the HTTP response body contains only the generic "registration failed" message +// and never the raw Go/PostgreSQL error string (issue #435). +func TestRegister_DBErrorResponseIsOpaque(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + broadcaster := newTestBroadcaster() + handler := NewRegistryHandler(broadcaster) + + // C18 pre-check — no live tokens (bootstrap path). + mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM workspace_auth_tokens"). + WithArgs("ws-errtest"). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0)) + + // DB upsert fails with a descriptive internal error. + mock.ExpectExec("INSERT INTO workspaces"). + WithArgs("ws-errtest", "ws-errtest", "http://localhost:9200", `{"name":"err-agent"}`). + WillReturnError(sql.ErrConnDone) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("POST", "/registry/register", + bytes.NewBufferString(`{"id":"ws-errtest","url":"http://localhost:9200","agent_card":{"name":"err-agent"}}`)) + c.Request.Header.Set("Content-Type", "application/json") + + handler.Register(c) + + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d: %s", w.Code, w.Body.String()) + } + + var resp map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("response is not valid JSON: %v — body: %s", err, w.Body.String()) + } + + errMsg, ok := resp["error"].(string) + if !ok { + t.Fatalf("expected string 'error' field, got %T: %v", resp["error"], resp["error"]) + } + if errMsg != "registration failed" { + t.Errorf("expected opaque 'registration failed', got %q (raw error leaked)", errMsg) + } + // Confirm the raw driver error string is absent. + rawBody := w.Body.String() + if strings.Contains(rawBody, "sql:") || strings.Contains(rawBody, "pq:") || strings.Contains(rawBody, "connection") { + t.Errorf("raw DB error leaked into response body: %s", rawBody) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet sqlmock expectations: %v", err) + } +} diff --git a/platform/internal/handlers/workspace_provision.go b/platform/internal/handlers/workspace_provision.go index e6ae3f7d..c24e554a 100644 --- a/platform/internal/handlers/workspace_provision.go +++ b/platform/internal/handlers/workspace_provision.go @@ -259,6 +259,25 @@ func findTemplateByName(configsDir, name string) string { return "" } +// resolveOrgTemplate looks for a matching role directory under +// configsDir/org-templates/ and returns the absolute path and a short label +// ("org-templates/"). Used by the restart handler's rebuild_config path +// (#239) so a workspace can recover from a destroyed config volume without +// admin intervention. +// Returns ("", "") when no match is found. +func resolveOrgTemplate(configsDir, wsName string) (path, label string) { + orgDir := filepath.Join(configsDir, "org-templates") + match := findTemplateByName(orgDir, wsName) + if match == "" { + return "", "" + } + full := filepath.Join(orgDir, match) + if _, err := os.Stat(full); err != nil { + return "", "" + } + return full, "org-templates/" + match +} + // configDirName returns the standard config directory name for a workspace ID. // Used by resolveConfigDir in templates.go for host-side template resolution. func configDirName(workspaceID string) string { diff --git a/platform/internal/handlers/workspace_provision_test.go b/platform/internal/handlers/workspace_provision_test.go index 9232b67a..d983a9ed 100644 --- a/platform/internal/handlers/workspace_provision_test.go +++ b/platform/internal/handlers/workspace_provision_test.go @@ -121,6 +121,66 @@ func TestFindTemplateByName_InvalidDir(t *testing.T) { } } +// ==================== resolveOrgTemplate ==================== + +// TestResolveOrgTemplate_HitByDirName verifies the happy path: org-templates/ +// dir exists with a normalized name match. +func TestResolveOrgTemplate_HitByDirName(t *testing.T) { + configsDir := t.TempDir() + orgDir := filepath.Join(configsDir, "org-templates") + roleDir := filepath.Join(orgDir, "technical-researcher") + os.MkdirAll(roleDir, 0755) + + path, label := resolveOrgTemplate(configsDir, "Technical Researcher") + if path != roleDir { + t.Errorf("expected path %q, got %q", roleDir, path) + } + if label != "org-templates/technical-researcher" { + t.Errorf("expected label %q, got %q", "org-templates/technical-researcher", label) + } +} + +// TestResolveOrgTemplate_HitByConfigYAML verifies the config.yaml name-field +// fallback works when the dir name doesn't match the workspace name directly. +func TestResolveOrgTemplate_HitByConfigYAML(t *testing.T) { + configsDir := t.TempDir() + orgDir := filepath.Join(configsDir, "org-templates") + roleDir := filepath.Join(orgDir, "org-backend") + os.MkdirAll(roleDir, 0755) + os.WriteFile(filepath.Join(roleDir, "config.yaml"), []byte("name: Backend Engineer\n"), 0644) + + path, label := resolveOrgTemplate(configsDir, "Backend Engineer") + if path != roleDir { + t.Errorf("expected path %q, got %q", roleDir, path) + } + if label != "org-templates/org-backend" { + t.Errorf("expected label %q, got %q", "org-templates/org-backend", label) + } +} + +// TestResolveOrgTemplate_NoOrgTemplatesDir returns empty when the org-templates +// directory does not exist. +func TestResolveOrgTemplate_NoOrgTemplatesDir(t *testing.T) { + configsDir := t.TempDir() // no org-templates subdir created + + path, label := resolveOrgTemplate(configsDir, "Technical Researcher") + if path != "" || label != "" { + t.Errorf("expected empty, got path=%q label=%q", path, label) + } +} + +// TestResolveOrgTemplate_NoMatchInOrgTemplates returns empty when org-templates +// exists but has no entry matching the workspace name. +func TestResolveOrgTemplate_NoMatchInOrgTemplates(t *testing.T) { + configsDir := t.TempDir() + os.MkdirAll(filepath.Join(configsDir, "org-templates", "seo-agent"), 0755) + + path, label := resolveOrgTemplate(configsDir, "Backend Engineer") + if path != "" || label != "" { + t.Errorf("expected empty, got path=%q label=%q", path, label) + } +} + // ==================== ensureDefaultConfig ==================== func TestEnsureDefaultConfig_LangGraph(t *testing.T) { diff --git a/platform/internal/handlers/workspace_restart.go b/platform/internal/handlers/workspace_restart.go index 503c6d68..3a263d39 100644 --- a/platform/internal/handlers/workspace_restart.go +++ b/platform/internal/handlers/workspace_restart.go @@ -107,6 +107,7 @@ func (h *WorkspaceHandler) Restart(c *gin.Context) { Template string `json:"template"` ApplyTemplate bool `json:"apply_template"` // force re-apply runtime-default template (e.g. after runtime change) Reset bool `json:"reset"` // #12: discard claude-sessions volume before restart + RebuildConfig bool `json:"rebuild_config"` // #239: re-render config volume from org-template source (recovery path when volume was destroyed) } c.ShouldBindJSON(&body) @@ -131,6 +132,17 @@ func (h *WorkspaceHandler) Restart(c *gin.Context) { } } + // #239: rebuild_config=true — try org-templates as last-resort source so a + // workspace with a destroyed config volume can self-recover without admin + // intervention. Only fires when no other template was resolved above. + if templatePath == "" && body.RebuildConfig { + if p, label := resolveOrgTemplate(h.configsDir, wsName); p != "" { + templatePath = p + configLabel = label + log.Printf("Restart: rebuild_config — using org-template %s for %s (%s)", label, wsName, id) + } + } + if templatePath == "" { log.Printf("Restart: reusing existing config volume for %s (%s)", wsName, id) } else {