From d08f237de98a6f8901ebcc358bb5c2061a9e78d0 Mon Sep 17 00:00:00 2001 From: "molecule-ai[bot]" <276602405+molecule-ai[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 00:46:20 +0000 Subject: [PATCH] fix(platform): reject self-delegation to prevent _run_lock deadlock (#570) When a workspace delegated a task to itself, it would acquire _run_lock twice on the same goroutine mutex, blocking permanently. Add an early-return guard in `DelegationHandler.Delegate` that returns HTTP 400 {"error": "self-delegation not permitted"} as soon as sourceID == body.TargetID, before any DB or A2A work is done. Adds TestDelegate_SelfDelegation_Rejected to delegation_test.go. Closes #548 Co-authored-by: Molecule AI Backend Engineer Co-authored-by: Claude Sonnet 4.6 --- platform/internal/handlers/delegation.go | 7 +++++ platform/internal/handlers/delegation_test.go | 31 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/platform/internal/handlers/delegation.go b/platform/internal/handlers/delegation.go index 7edaab65..89fd2220 100644 --- a/platform/internal/handlers/delegation.go +++ b/platform/internal/handlers/delegation.go @@ -54,6 +54,13 @@ func (h *DelegationHandler) Delegate(c *gin.Context) { return // response already written } + // #548 — prevent self-delegation: a workspace delegating to itself + // acquires _run_lock twice on the same mutex, deadlocking permanently. + if sourceID == body.TargetID { + c.JSON(http.StatusBadRequest, gin.H{"error": "self-delegation not permitted"}) + return + } + // #124 — idempotency. If the caller supplies an idempotency_key, return // the existing delegation when (workspace_id, idempotency_key) already // exists and is not in a failed terminal state. diff --git a/platform/internal/handlers/delegation_test.go b/platform/internal/handlers/delegation_test.go index e9e8ca69..094b419b 100644 --- a/platform/internal/handlers/delegation_test.go +++ b/platform/internal/handlers/delegation_test.go @@ -88,6 +88,37 @@ func TestDelegate_InvalidUUIDTargetID(t *testing.T) { } } +// ---------- Delegate: self-delegation → 400 ---------- + +func TestDelegate_SelfDelegation_Rejected(t *testing.T) { + setupTestDB(t) + setupTestRedis(t) + broadcaster := newTestBroadcaster() + wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir()) + dh := NewDelegationHandler(wh, broadcaster) + + // Use the same UUID for both source and target to trigger the self-delegation guard. + selfID := "11111111-2222-3333-4444-555555555555" + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: selfID}} + body := `{"target_id":"` + selfID + `","task":"do something"}` + c.Request = httptest.NewRequest("POST", "/workspaces/"+selfID+"/delegate", bytes.NewBufferString(body)) + c.Request.Header.Set("Content-Type", "application/json") + + dh.Delegate(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String()) + } + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if resp["error"] != "self-delegation not permitted" { + t.Errorf("expected 'self-delegation not permitted', got %v", resp["error"]) + } +} + // ---------- Delegate: success → 202 with delegation_id ---------- func TestDelegate_Success(t *testing.T) {