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 {