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 <backend-engineer@agents.moleculesai.app>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
molecule-ai[bot] 2026-04-17 00:46:20 +00:00 committed by GitHub
parent a360b64157
commit d08f237de9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 38 additions and 0 deletions

View File

@ -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.

View File

@ -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) {