From e9c4f23ae270a0dc5792b4a415297657f5f6341c Mon Sep 17 00:00:00 2001 From: core-fe Date: Wed, 20 May 2026 21:53:28 -0700 Subject: [PATCH] fix(core): guard external a2a loopback routing --- .../internal/handlers/a2a_proxy.go | 19 +++++++++--- .../internal/handlers/a2a_proxy_test.go | 29 +++++++++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/workspace-server/internal/handlers/a2a_proxy.go b/workspace-server/internal/handlers/a2a_proxy.go index 322ee4efa..7689ddd33 100644 --- a/workspace-server/internal/handlers/a2a_proxy.go +++ b/workspace-server/internal/handlers/a2a_proxy.go @@ -686,11 +686,22 @@ func (h *WorkspaceHandler) resolveAgentURL(ctx context.Context, workspaceID stri _ = db.CacheURL(ctx, workspaceID, agentURL) } - // When the platform runs inside Docker, 127.0.0.1:{host_port} is - // unreachable (it's the platform container's own localhost, not the - // Docker host). Rewrite to the container's Docker-bridge hostname. + // When the platform runs inside Docker, a managed workspace's + // 127.0.0.1:{host_port} URL points at the Docker host and must be + // rewritten to the workspace container's Docker-bridge hostname. + // External runtimes are not managed containers; their local test/runtime + // URL is the target and must not be synthesized into ws-:8000. if strings.HasPrefix(agentURL, "http://127.0.0.1:") && h.provisioner != nil && platformInDocker { - agentURL = provisioner.InternalURL(workspaceID) + var wsRuntime string + if err := db.DB.QueryRowContext(ctx, + `SELECT COALESCE(runtime, 'langgraph') FROM workspaces WHERE id = $1`, + workspaceID, + ).Scan(&wsRuntime); err != nil { + log.Printf("ProxyA2A: runtime lookup before Docker URL rewrite failed for %s: %v", workspaceID, err) + } + if !isExternalLikeRuntime(wsRuntime) { + agentURL = provisioner.InternalURL(workspaceID) + } } // SSRF defence: reject private/metadata URLs before making outbound call. if err := isSafeURL(agentURL); err != nil { diff --git a/workspace-server/internal/handlers/a2a_proxy_test.go b/workspace-server/internal/handlers/a2a_proxy_test.go index d2173d4c3..930e747ba 100644 --- a/workspace-server/internal/handlers/a2a_proxy_test.go +++ b/workspace-server/internal/handlers/a2a_proxy_test.go @@ -1511,6 +1511,35 @@ func TestResolveAgentURL_DockerRewrite(t *testing.T) { } } +func TestResolveAgentURL_ExternalRuntimeLoopbackNotRewrittenInDocker(t *testing.T) { + mock := setupTestDB(t) + mr := setupTestRedis(t) + allowLoopbackForTest(t) + handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir()) + waitForHandlerAsyncBeforeDBCleanup(t, handler) + handler.provisioner = &stubLocalProv{} + + restore := setPlatformInDockerForTest(true) + defer restore() + + agentURL := "http://127.0.0.1:55555" + mr.Set("ws:ws-external:url", agentURL) + mock.ExpectQuery("SELECT COALESCE\\(runtime"). + WithArgs("ws-external"). + WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("external")) + + url, perr := handler.resolveAgentURL(context.Background(), "ws-external") + if perr != nil { + t.Fatalf("unexpected error: %+v", perr) + } + if url != agentURL { + t.Errorf("external runtime loopback URL must not be rewritten; got %q want %q", url, agentURL) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet sqlmock expectations: %v", err) + } +} + // --- dispatchA2A direct unit tests --- func TestDispatchA2A_BuildRequestError(t *testing.T) { -- 2.52.0