diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 00000000..8fca9a01
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,47 @@
+.git
+.github
+.env
+.env.*
+!.env.example
+*.pem
+*.key
+*.p12
+*.pfx
+*.jks
+*.keystore
+*.creds
+appsecrets
+env.sh
+storage-*.json
+
+.venv
+**/.venv
+node_modules
+**/node_modules
+__pycache__
+**/__pycache__
+*.py[cod]
+.pytest_cache
+**/.pytest_cache
+.mypy_cache
+**/.mypy_cache
+.ruff_cache
+**/.ruff_cache
+.benchmarks
+.coverage
+.coverage.*
+htmlcov
+dist
+build
+site
+*.egg-info
+
+**/tests
+**/test
+**/cassettes
+.vscode
+.idea
+.DS_Store
+*.log
+*.bak
+SECURITY_SCAN_REPORT.md
diff --git a/.github/workflows/bd_sca_scanner.yaml b/.github/workflows/bd_sca_scanner.yaml
index 6066ca39..0131b0e7 100644
--- a/.github/workflows/bd_sca_scanner.yaml
+++ b/.github/workflows/bd_sca_scanner.yaml
@@ -5,11 +5,15 @@ on:
- main
paths:
- '**/*.lock'
+ - '**/package.json'
+ - '**/package-lock.json'
pull_request:
branches:
- main
paths:
- '**/*.lock'
+ - '**/package.json'
+ - '**/package-lock.json'
workflow_dispatch:
jobs:
@@ -30,9 +34,9 @@ jobs:
id: calculate-detect-args
run: |
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
- echo "DETECT_ARGS=--detect.excluded.directories=.bridge --detect.included.detector.types=uv --detect.uv.dependency.groups.excluded=dev --detect.timeout=600 --detect.excluded.detector.types=GIT --detect.blackduck.rapid.compare.mode=BOM_COMPARE_STRICT" >> $GITHUB_OUTPUT
+ echo "DETECT_ARGS=--detect.excluded.directories=.bridge --detect.included.detector.types=uv,npm --detect.uv.dependency.groups.excluded=dev --detect.timeout=600 --detect.excluded.detector.types=GIT --detect.blackduck.rapid.compare.mode=BOM_COMPARE_STRICT" >> $GITHUB_OUTPUT
else
- echo "DETECT_ARGS=--detect.excluded.directories=.bridge --detect.included.detector.types=uv --detect.uv.dependency.groups.excluded=dev --detect.timeout=600 --detect.excluded.detector.types=GIT" >> $GITHUB_OUTPUT
+ echo "DETECT_ARGS=--detect.excluded.directories=.bridge --detect.included.detector.types=uv,npm --detect.uv.dependency.groups.excluded=dev --detect.timeout=600 --detect.excluded.detector.types=GIT" >> $GITHUB_OUTPUT
fi
- name: Run Black Duck SCA PR Scan
@@ -49,6 +53,6 @@ jobs:
blackducksca_scan_full: ${{ github.event_name == 'pull_request' && 'false' || 'true' }}
project_directory: '.'
blackducksca_scan_failure_severities: 'BLOCKER,CRITICAL,MAJOR'
- detect_search_depth: 1
+ detect_search_depth: 3
detect_args: ${{ steps.calculate-detect-args.outputs.DETECT_ARGS }}
include_diagnostics: false
diff --git a/.github/workflows/hyperforge_workflow.yaml b/.github/workflows/hyperforge_workflow.yaml
index ed569306..47565dcf 100644
--- a/.github/workflows/hyperforge_workflow.yaml
+++ b/.github/workflows/hyperforge_workflow.yaml
@@ -47,6 +47,20 @@ jobs:
- name: Install package dependencies with uv
run: uv sync --group dev
+ - name: Set up Node.js
+ uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
+ with:
+ node-version: "22.22.0"
+ cache: npm
+ cache-dependency-path: hyperforge/frontend/package-lock.json
+
+ - name: Audit and build frontend
+ working-directory: hyperforge/frontend
+ run: |
+ npm ci
+ npm audit --audit-level=moderate
+ npm run build
+
- name: Lint
working-directory: hyperforge
run: make lint
diff --git a/HYPERFORGE.Dockerfile b/HYPERFORGE.Dockerfile
index 689de342..457e32bb 100644
--- a/HYPERFORGE.Dockerfile
+++ b/HYPERFORGE.Dockerfile
@@ -1,22 +1,42 @@
-FROM python:3.14 AS build
+FROM python:3.14.7-slim-trixie@sha256:83ff1d245a3d57d04152252d3ef9cb361494d0b3395abd65a5ebe91c401c8e83 AS build
-RUN pip install uv && apt update -y && apt install -y npm && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
+COPY --from=ghcr.io/astral-sh/uv:0.10.8@sha256:88234bc9e09c2b2f6d176a3daf411419eb0370d450a08129257410de9cfafd2a /uv /usr/local/bin/uv
-# Install dependencies
-RUN uv venv /app
-ENV VIRTUAL_ENV=/app
-COPY . /app/.
-RUN uv sync --active --frozen --directory /app --compile-bytecode
+RUN apt-get update \
+ && apt-get install --no-install-recommends -y build-essential libpq-dev \
+ && rm -rf /var/lib/apt/lists/*
-#
-# Only copy the virtual env to the final image.
-#
-FROM python:3.14
-COPY --from=build /app /app
-ENV PATH=/app/bin:$PATH
+WORKDIR /src
+COPY pyproject.toml uv.lock ./
+COPY hyperforge/ hyperforge/
+COPY agents/ agents/
+
+RUN uv venv /opt/venv \
+ && VIRTUAL_ENV=/opt/venv uv sync --active --frozen --no-dev --no-editable --compile-bytecode
+
+FROM python:3.14.7-slim-trixie@sha256:83ff1d245a3d57d04152252d3ef9cb361494d0b3395abd65a5ebe91c401c8e83
+
+RUN apt-get update \
+ && apt-get install --no-install-recommends -y libpq5 \
+ && rm -rf /var/lib/apt/lists/* \
+ && groupadd --gid 10001 hyperforge \
+ && useradd --uid 10001 --gid 10001 --create-home hyperforge
+
+COPY --from=build --chown=10001:10001 /opt/venv /opt/venv
+
+ENV PATH="/opt/venv/bin:${PATH}" \
+ PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1
+
+WORKDIR /home/hyperforge
+USER 10001:10001
+EXPOSE 8080
+HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
+ CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/ready', timeout=2)"]
+CMD ["hyperforge-api"]
# OCI metadata: links this image to the GitHub repo as a package, with source + license.
LABEL org.opencontainers.image.source="https://github.com/nuclia/hyperforge" \
org.opencontainers.image.description="Agentic workflow on top of NUA" \
org.opencontainers.image.licenses="Apache-2.0" \
- org.opencontainers.image.authors="Nuclia Team"
\ No newline at end of file
+ org.opencontainers.image.authors="Nuclia Team"
diff --git a/agents/external/src/hyperforge_external/agent.py b/agents/external/src/hyperforge_external/agent.py
index 5f0d3453..4d9e8d3f 100644
--- a/agents/external/src/hyperforge_external/agent.py
+++ b/agents/external/src/hyperforge_external/agent.py
@@ -7,8 +7,7 @@
from hyperforge.manager import Manager
from hyperforge.memory import QuestionMemory
from hyperforge.trace import trace_agent
-from hyperforge.utils import check_dns
-from hyperforge.utils.http import safe_http_client
+from hyperforge.utils.http import read_limited_response, safe_http_client
from hyperforge_external.config import ExternalCallAgentConfig
@@ -61,9 +60,8 @@ async def __call__(
output_nuclia_tokens: float = 0.0
evaluation = None
- async with safe_http_client() as client:
- url = await check_dns(self.config.url)
-
+ response_content = b""
+ async with safe_http_client(timeout=self.config.timeout) as client:
if self.config.call_schema:
logger.debug(
f"Calling external API with schema: {self.config.call_schema}, description: {self.config.description}"
@@ -83,9 +81,9 @@ async def __call__(
output_nuclia_tokens += output_tokens
input_nuclia_tokens += input_tokens
logger.debug(f"Json to do the call: {evaluation}")
- resp = await client.request(
+ request = client.build_request(
method=self.config.method.value,
- url=url,
+ url=self.config.url,
json=evaluation,
headers=self.config.headers,
)
@@ -93,9 +91,9 @@ async def __call__(
logger.debug(
f"Calling external API with generated context: {memory.contexts}"
)
- resp = await client.request(
+ request = client.build_request(
method=self.config.method.value,
- url=url,
+ url=self.config.url,
json=memory.contexts,
headers=self.config.headers,
)
@@ -109,26 +107,38 @@ async def __call__(
evaluation = {}
evaluation["answer"] = memory.final_answer
evaluation["question"] = memory.original_question
- resp = await client.request(
+ request = client.build_request(
method=self.config.method.value,
url=self.config.url,
json=evaluation,
headers=self.config.headers,
)
+ resp = await client.send(request, stream=True)
+ try:
+ response_content = await read_limited_response(
+ resp, self.config.max_response_bytes
+ )
+ finally:
+ await resp.aclose()
error_resp = None
if resp is None:
error_resp = "No response from external API"
elif resp.status_code != 200:
- error_resp = f"Error calling external API: {resp.status_code} - {resp.content.decode()}"
+ error_resp = f"Error calling external API: {resp.status_code}"
if error_resp:
logger.error(error_resp)
raise Exception(error_resp)
- logger.info(f"Response from external API: {resp.content.decode()}")
+ response_text = response_content.decode("utf-8", errors="replace")
+ logger.info(
+ "External API returned status %s and %s bytes",
+ resp.status_code,
+ len(response_content),
+ )
await memory.add_step(
step_module="external",
step_title=self.step_title(self.config.method.value),
step_value=self.config.url,
- step_reason=resp.content.decode(),
+ step_reason=response_text,
timeit=time() - t0,
step_agent_path=f"/postprocess/{self.config.id if self.config.id else 'default'}",
input_nuclia_tokens=input_nuclia_tokens,
diff --git a/agents/external/src/hyperforge_external/config.py b/agents/external/src/hyperforge_external/config.py
index a013dea9..22c8018c 100644
--- a/agents/external/src/hyperforge_external/config.py
+++ b/agents/external/src/hyperforge_external/config.py
@@ -65,6 +65,13 @@ class ExternalCallAgentConfig(AgentConfig):
title="URL to call",
description="",
)
+ timeout: int = Field(default=30, ge=1, le=300)
+ max_response_bytes: int = Field(
+ default=1024 * 1024,
+ ge=1,
+ le=10 * 1024 * 1024,
+ description="Maximum accepted HTTP response size in bytes",
+ )
@field_validator("url")
def validate_url(cls, v):
diff --git a/agents/external/tests/test_external.py b/agents/external/tests/test_external.py
index adbfdbf4..a44555ab 100644
--- a/agents/external/tests/test_external.py
+++ b/agents/external/tests/test_external.py
@@ -1,6 +1,7 @@
import os
-from unittest.mock import AsyncMock, MagicMock
+from unittest.mock import AsyncMock
+import httpx
import pytest
from hyperforge.engine import main as arag_main
from hyperforge.minimal_fixtures import cassette_nua_key
@@ -55,12 +56,12 @@
@pytest.mark.vcr
@pytest.mark.asyncio
async def test_external(mocker):
- mock_response = MagicMock()
- mock_response.status_code = 200
- mock_response.content = b"oki doki"
+ request = httpx.Request("POST", "https://example.com/aaa")
+ mock_response = httpx.Response(200, content=b"oki doki", request=request)
mock_client = AsyncMock()
- mock_client.request = AsyncMock(return_value=mock_response)
+ mock_client.build_request.return_value = request
+ mock_client.send = AsyncMock(return_value=mock_response)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
@@ -87,4 +88,4 @@ async def test_external(mocker):
"partner" in question_memory.steps[-2].reason
or "Peugeot" in question_memory.steps[-2].reason
)
- assert mock_client.request.called
+ assert mock_client.send.called
diff --git a/agents/http/src/hyperforge_http/agent.py b/agents/http/src/hyperforge_http/agent.py
index f8583fd4..d0d54fd1 100644
--- a/agents/http/src/hyperforge_http/agent.py
+++ b/agents/http/src/hyperforge_http/agent.py
@@ -7,8 +7,7 @@
from hyperforge.context.agent import ContextAgent
from hyperforge.manager import Manager
from hyperforge.memory import Chunk, Context, QuestionMemory
-from hyperforge.utils import check_dns
-from hyperforge.utils.http import safe_http_client
+from hyperforge.utils.http import read_limited_response, safe_http_client
from hyperforge_http.config import HTTPStaticAgentConfig
@@ -33,24 +32,22 @@ async def _get_question_context(
t0 = time()
error = None
- url = await check_dns(self.config.url)
-
async with safe_http_client() as client:
if self.config.method == "GET":
- response = await client.get(
- url,
+ request = client.build_request(
+ "GET",
+ self.config.url,
headers=self.config.headers,
- timeout=self.config.timeout,
params={self.config.question_query_param: question}
if self.config.question_query_param
else None,
)
elif self.config.method == "POST":
- response = await client.post(
- url,
+ request = client.build_request(
+ "POST",
+ self.config.url,
headers=self.config.headers,
- timeout=self.config.timeout,
params={self.config.question_query_param: question}
if self.config.question_query_param
else None,
@@ -61,11 +58,19 @@ async def _get_question_context(
else:
raise ValueError(f"Unsupported HTTP method: {self.config.method}")
+ response = await client.send(request, stream=True)
+ try:
+ response_content = await read_limited_response(
+ response, self.config.max_response_bytes
+ )
+ finally:
+ await response.aclose()
+
if response.status_code != 200:
error = f"HTTP request failed with status code {response.status_code}"
context_text = ""
else:
- context_text = response.content.decode("utf-8")
+ context_text = response_content.decode("utf-8", errors="replace")
context = Context(
agent_id=self.config.id if self.config.id else "http",
@@ -88,7 +93,7 @@ async def _get_question_context(
step_module=self.config.module,
step_title=self.step_title("HTTP request"),
step_agent_path=f"/context/{self.config.id if self.config.id else 'default'}",
- step_value=f" HTTP {self.config.method} to {url}",
+ step_value=f" HTTP {self.config.method} to {self.config.url}",
timeit=time() - t0,
input_nuclia_tokens=0,
output_nuclia_tokens=0,
diff --git a/agents/http/src/hyperforge_http/config.py b/agents/http/src/hyperforge_http/config.py
index 482acf40..803e6263 100644
--- a/agents/http/src/hyperforge_http/config.py
+++ b/agents/http/src/hyperforge_http/config.py
@@ -28,7 +28,13 @@ class HTTPStaticAgentConfig(ContextAgentConfig):
description="Optional POST field id to include the question in the request body",
)
timeout: int = Field(
- default=10, description="Timeout for the HTTP request in seconds"
+ default=10, ge=1, le=300, description="Timeout for the HTTP request in seconds"
+ )
+ max_response_bytes: int = Field(
+ default=1024 * 1024,
+ ge=1,
+ le=10 * 1024 * 1024,
+ description="Maximum accepted HTTP response size in bytes",
)
@field_validator("url")
diff --git a/agents/http/tests/test_http_static.py b/agents/http/tests/test_http_static.py
index 78232159..882c310d 100644
--- a/agents/http/tests/test_http_static.py
+++ b/agents/http/tests/test_http_static.py
@@ -1,6 +1,7 @@
import os
-from unittest.mock import AsyncMock, MagicMock
+from unittest.mock import AsyncMock
+import httpx
import pytest
from hyperforge.engine import main as arag_main
from hyperforge.interaction import AragAnswer
@@ -46,12 +47,16 @@
async def test_http_static_get(mocker):
answers = []
- mock_response = MagicMock()
- mock_response.status_code = 200
- mock_response.content = b"Experts from the University of Nuclia have found that waking up relaxed can be achieved by following a consistent sleep schedule, creating a calming bedtime routine, and ensuring a comfortable sleep environment. Also, if your alarm is a an BetterStack call, it will help you clear your mind"
+ request = httpx.Request("GET", "https://example.com/context")
+ mock_response = httpx.Response(
+ 200,
+ content=b"Experts from the University of Nuclia have found that waking up relaxed can be achieved by following a consistent sleep schedule, creating a calming bedtime routine, and ensuring a comfortable sleep environment. Also, if your alarm is a an BetterStack call, it will help you clear your mind",
+ request=request,
+ )
mock_client = AsyncMock()
- mock_client.get = AsyncMock(return_value=mock_response)
+ mock_client.build_request.return_value = request
+ mock_client.send = AsyncMock(return_value=mock_response)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
diff --git a/agents/mcp/src/hyperforge_mcp/agent.py b/agents/mcp/src/hyperforge_mcp/agent.py
index e885e1bf..0e7ae3a3 100644
--- a/agents/mcp/src/hyperforge_mcp/agent.py
+++ b/agents/mcp/src/hyperforge_mcp/agent.py
@@ -176,8 +176,15 @@ async def get_tool_selection_prompt(
if final_prompt is None:
# We will use LLM to choose the prompt
- prompt_feedback_str = PROMPT_CHOOSE_TEMPLATE.render(prompts=self.prompts)
- resp, input_tokens, output_tokens = await manager.execute_json(
+ prompt_feedback_str = PROMPT_CHOOSE_TEMPLATE.render(
+ prompts=self.prompts, task_description=question
+ )
+ (
+ resp,
+ input_tokens,
+ output_tokens,
+ reasoning,
+ ) = await manager.execute_json_reasoning(
model=self.config.tool_choice_model,
prompt=prompt_feedback_str,
user_id="mcp_no_feedback",
diff --git a/agents/mcp/src/hyperforge_mcp/config_driver.py b/agents/mcp/src/hyperforge_mcp/config_driver.py
index 6299fc3a..bbed3344 100644
--- a/agents/mcp/src/hyperforge_mcp/config_driver.py
+++ b/agents/mcp/src/hyperforge_mcp/config_driver.py
@@ -18,8 +18,6 @@ def _redirect_uris_schema_default(schema: Dict[str, Any]) -> None:
class MCPHTTPInnerConfig(EncryptedPayload):
- encrypted_fields: ClassVar[list[str]] = ["client_secret"]
-
uri: str
timeout: float = 60 * 5
headers: Dict[str, str] = Field(default_factory=dict)
@@ -120,7 +118,7 @@ class MCPStdioServer(str, Enum):
class MCPStdioInnerConfig(EncryptedPayload):
- encrypted_fields: ClassVar[list[str]] = []
+ encrypted_fields: ClassVar[list[str]] = ["env"]
server: MCPStdioServer
env: dict[str, str] | None = None
diff --git a/agents/mcp/src/hyperforge_mcp/http.py b/agents/mcp/src/hyperforge_mcp/http.py
index d17842a8..7578fd90 100644
--- a/agents/mcp/src/hyperforge_mcp/http.py
+++ b/agents/mcp/src/hyperforge_mcp/http.py
@@ -1,11 +1,13 @@
import asyncio
import base64
import hashlib
+import os
import secrets
import ssl
import tempfile
from collections.abc import Awaitable, Callable
from functools import cache, partial
+from pathlib import Path
from typing import Any, Optional
from urllib.parse import parse_qs, urlencode, urlparse
from uuid import uuid4
@@ -478,10 +480,18 @@ def create_mcp_http_client(
if certificate is not None:
if len(certificate) > 32_000:
raise ValueError("Certificate is too large")
- with tempfile.NamedTemporaryFile(mode="w+", delete=False) as cert_file:
- cert_file.write(certificate)
-
- ssl_context.load_cert_chain(certfile=cert_file.name)
+ cert_path: str | None = None
+ try:
+ with tempfile.NamedTemporaryFile(
+ mode="w", delete=False, encoding="utf-8"
+ ) as cert_file:
+ cert_path = cert_file.name
+ cert_file.write(certificate)
+ os.chmod(cert_path, 0o600)
+ ssl_context.load_cert_chain(certfile=cert_path)
+ finally:
+ if cert_path is not None:
+ Path(cert_path).unlink(missing_ok=True)
safe_transport = SafeTransport(verify=ssl_context)
else:
diff --git a/agents/mcp/src/hyperforge_mcp/multi.py b/agents/mcp/src/hyperforge_mcp/multi.py
index 6fbc984a..388a6c96 100644
--- a/agents/mcp/src/hyperforge_mcp/multi.py
+++ b/agents/mcp/src/hyperforge_mcp/multi.py
@@ -102,7 +102,10 @@ async def multi_choose_tool(
images: list[Image],
):
t0 = time()
- prompt_feedback = TOOLS_CHOOSE_TEMPLATE.render(tools=self.main_agent.tools)
+ prompt = " ".join([m.text for m in messages])
+ prompt_feedback = TOOLS_CHOOSE_TEMPLATE.render(
+ tools=self.main_agent.tools, task_description=prompt
+ )
resp, input_tokens, output_tokens = await manager.execute_json(
model=self.config.tool_choice_model,
prompt=prompt_feedback,
@@ -178,7 +181,7 @@ async def get_multi_tool_selection_prompt(
if agent_obj is None:
# We will use LLM to choose the prompt
prompt_feedback_str = PROMPT_CHOOSE_TEMPLATE.render(
- prompts=self.main_agent.prompts
+ prompts=self.main_agent.prompts, task_description=question
)
resp, input_tokens, output_tokens = await manager.execute_json(
model=self.config.tool_choice_model,
diff --git a/agents/mcp/src/hyperforge_mcp/tools.py b/agents/mcp/src/hyperforge_mcp/tools.py
index b3d525d8..d9a43869 100644
--- a/agents/mcp/src/hyperforge_mcp/tools.py
+++ b/agents/mcp/src/hyperforge_mcp/tools.py
@@ -7,6 +7,8 @@
{{prompt.description}}
{% endfor -%}
+Task: {{task_description}}
+
"""
PROMPT_CHOOSE_TEMPLATE = PROMPT_ENVIRONMENT.from_string(PROMPT_CHOOSE)
@@ -15,19 +17,10 @@
Choose proper toolset to solve the task. If none of the toolsets is suitable, do not choose any. Make sure to choose only one toolset.
{% for tool in tools %}
-{{tool.description}}
+{{tool.description}}
{% endfor -%}
-"""
-
-PROMPT_CHOOSE_TEMPLATE = PROMPT_ENVIRONMENT.from_string(PROMPT_CHOOSE)
-
-PROMPT_CHOOSE = """
-Choose proper prompt to solve the task. If none of the prompts is suitable, do not choose any. Make sure to choose only one prompt.
-
-{% for prompt in prompts %}
-{{prompt.description}}
-{% endfor -%}
+Task: {{task_description}}
"""
diff --git a/agents/mcp/tests/test_mcp.py b/agents/mcp/tests/test_mcp.py
index dec7a342..2fc8f553 100644
--- a/agents/mcp/tests/test_mcp.py
+++ b/agents/mcp/tests/test_mcp.py
@@ -6,7 +6,6 @@
from hyperforge.engine import main as arag_main
from hyperforge.interaction import AragAnswer
from hyperforge.minimal_fixtures import cassette_nua_key
-from hyperforge.utils.http import SafeTransport
from hyperforge_mcp.agent import _tool_parameters
@@ -86,7 +85,10 @@ async def test_tool_parameters_preserve_array_item_schema():
@pytest.fixture
async def disable_safe_transport():
- with patch.object(SafeTransport, "is_private_address", return_value=False):
+ with patch(
+ "hyperforge.utils.http._resolve_public_addresses",
+ return_value=["127.0.0.1"],
+ ):
yield
diff --git a/agents/restricted/src/hyperforge_restricted/sandbox.py b/agents/restricted/src/hyperforge_restricted/sandbox.py
index d9a32495..7b5366b1 100644
--- a/agents/restricted/src/hyperforge_restricted/sandbox.py
+++ b/agents/restricted/src/hyperforge_restricted/sandbox.py
@@ -30,7 +30,7 @@
class SandboxSettings(BaseSettings):
# Verify that the sandbox is secure on startup or raise an exception
- sandbox_verify: bool = False
+ sandbox_verify: bool = True
# Socket for the sandbox server to listen to
sandbox_socket: str | None = None
diff --git a/agents/restricted/tests/test_sandbox.py b/agents/restricted/tests/test_sandbox.py
index e7e6169a..8825468c 100644
--- a/agents/restricted/tests/test_sandbox.py
+++ b/agents/restricted/tests/test_sandbox.py
@@ -17,9 +17,15 @@
async def sandbox_conn():
with tempfile.TemporaryDirectory() as path:
socket = f"{path}/sandbox.sock"
- with patch(
- "hyperforge_restricted.sandbox.settings.sandbox_socket",
- socket,
+ with (
+ patch(
+ "hyperforge_restricted.sandbox.settings.sandbox_socket",
+ socket,
+ ),
+ patch(
+ "hyperforge_restricted.sandbox.settings.sandbox_verify",
+ False,
+ ),
):
task = asyncio.create_task(sandbox.run_sandbox_server())
diff --git a/hyperforge/frontend/package-lock.json b/hyperforge/frontend/package-lock.json
index 06bade48..022c364f 100644
--- a/hyperforge/frontend/package-lock.json
+++ b/hyperforge/frontend/package-lock.json
@@ -13,15 +13,17 @@
"@vue-flow/core": "^1.41.8",
"@vue-flow/minimap": "^1.5.0",
"pinia": "^3.0.0",
- "uuid": "^9.0.0",
+ "uuid": "11.1.1",
"vue": "^3.4.0",
"vue-router": "^5.0.0"
},
"devDependencies": {
"@types/uuid": "^9.0.7",
- "@vitejs/plugin-vue": "^5.0.4",
+ "@vitejs/plugin-vue": "6.0.8",
+ "esbuild": "0.28.1",
+ "postcss": "8.5.23",
"typescript": "^5.3.3",
- "vite": "^7.0.0",
+ "vite": "7.3.6",
"vue-tsc": "^3.0.0"
}
},
@@ -135,9 +137,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
- "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
+ "version": "0.28.1",
+ "resolved": "https://pkg.harness.io/pkg/ct8onj8YTdaXtKaFsYCRLg/org-progress_all-npm/npm/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
+ "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
@@ -151,9 +153,9 @@
}
},
"node_modules/@esbuild/android-arm": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
- "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
+ "version": "0.28.1",
+ "resolved": "https://pkg.harness.io/pkg/ct8onj8YTdaXtKaFsYCRLg/org-progress_all-npm/npm/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
+ "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
@@ -167,9 +169,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
- "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
+ "version": "0.28.1",
+ "resolved": "https://pkg.harness.io/pkg/ct8onj8YTdaXtKaFsYCRLg/org-progress_all-npm/npm/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
+ "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
@@ -183,9 +185,9 @@
}
},
"node_modules/@esbuild/android-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
- "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
+ "version": "0.28.1",
+ "resolved": "https://pkg.harness.io/pkg/ct8onj8YTdaXtKaFsYCRLg/org-progress_all-npm/npm/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
+ "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
@@ -199,9 +201,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
- "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
+ "version": "0.28.1",
+ "resolved": "https://pkg.harness.io/pkg/ct8onj8YTdaXtKaFsYCRLg/org-progress_all-npm/npm/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
+ "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
@@ -215,9 +217,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
- "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
+ "version": "0.28.1",
+ "resolved": "https://pkg.harness.io/pkg/ct8onj8YTdaXtKaFsYCRLg/org-progress_all-npm/npm/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
+ "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
@@ -231,9 +233,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
- "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
+ "version": "0.28.1",
+ "resolved": "https://pkg.harness.io/pkg/ct8onj8YTdaXtKaFsYCRLg/org-progress_all-npm/npm/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
@@ -247,9 +249,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
- "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
+ "version": "0.28.1",
+ "resolved": "https://pkg.harness.io/pkg/ct8onj8YTdaXtKaFsYCRLg/org-progress_all-npm/npm/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
@@ -263,9 +265,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
- "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
+ "version": "0.28.1",
+ "resolved": "https://pkg.harness.io/pkg/ct8onj8YTdaXtKaFsYCRLg/org-progress_all-npm/npm/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
+ "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
@@ -279,9 +281,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
- "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
+ "version": "0.28.1",
+ "resolved": "https://pkg.harness.io/pkg/ct8onj8YTdaXtKaFsYCRLg/org-progress_all-npm/npm/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
+ "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
@@ -295,9 +297,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
- "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
+ "version": "0.28.1",
+ "resolved": "https://pkg.harness.io/pkg/ct8onj8YTdaXtKaFsYCRLg/org-progress_all-npm/npm/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
+ "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
@@ -311,9 +313,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
- "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
+ "version": "0.28.1",
+ "resolved": "https://pkg.harness.io/pkg/ct8onj8YTdaXtKaFsYCRLg/org-progress_all-npm/npm/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
+ "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
@@ -327,9 +329,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
- "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
+ "version": "0.28.1",
+ "resolved": "https://pkg.harness.io/pkg/ct8onj8YTdaXtKaFsYCRLg/org-progress_all-npm/npm/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
+ "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
@@ -343,9 +345,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
- "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
+ "version": "0.28.1",
+ "resolved": "https://pkg.harness.io/pkg/ct8onj8YTdaXtKaFsYCRLg/org-progress_all-npm/npm/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
+ "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
@@ -359,9 +361,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
- "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
+ "version": "0.28.1",
+ "resolved": "https://pkg.harness.io/pkg/ct8onj8YTdaXtKaFsYCRLg/org-progress_all-npm/npm/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
+ "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
@@ -375,9 +377,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
- "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
+ "version": "0.28.1",
+ "resolved": "https://pkg.harness.io/pkg/ct8onj8YTdaXtKaFsYCRLg/org-progress_all-npm/npm/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
+ "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
@@ -391,9 +393,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
- "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
+ "version": "0.28.1",
+ "resolved": "https://pkg.harness.io/pkg/ct8onj8YTdaXtKaFsYCRLg/org-progress_all-npm/npm/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
+ "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
@@ -407,9 +409,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
- "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
+ "version": "0.28.1",
+ "resolved": "https://pkg.harness.io/pkg/ct8onj8YTdaXtKaFsYCRLg/org-progress_all-npm/npm/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
@@ -423,9 +425,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
- "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
+ "version": "0.28.1",
+ "resolved": "https://pkg.harness.io/pkg/ct8onj8YTdaXtKaFsYCRLg/org-progress_all-npm/npm/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
@@ -439,9 +441,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
- "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
+ "version": "0.28.1",
+ "resolved": "https://pkg.harness.io/pkg/ct8onj8YTdaXtKaFsYCRLg/org-progress_all-npm/npm/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
@@ -455,9 +457,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
- "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
+ "version": "0.28.1",
+ "resolved": "https://pkg.harness.io/pkg/ct8onj8YTdaXtKaFsYCRLg/org-progress_all-npm/npm/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
@@ -471,9 +473,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
- "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
+ "version": "0.28.1",
+ "resolved": "https://pkg.harness.io/pkg/ct8onj8YTdaXtKaFsYCRLg/org-progress_all-npm/npm/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
+ "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
@@ -487,9 +489,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
- "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
+ "version": "0.28.1",
+ "resolved": "https://pkg.harness.io/pkg/ct8onj8YTdaXtKaFsYCRLg/org-progress_all-npm/npm/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+ "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
@@ -503,9 +505,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
- "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
+ "version": "0.28.1",
+ "resolved": "https://pkg.harness.io/pkg/ct8onj8YTdaXtKaFsYCRLg/org-progress_all-npm/npm/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+ "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
@@ -519,9 +521,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
- "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
+ "version": "0.28.1",
+ "resolved": "https://pkg.harness.io/pkg/ct8onj8YTdaXtKaFsYCRLg/org-progress_all-npm/npm/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+ "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
@@ -535,9 +537,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
- "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
+ "version": "0.28.1",
+ "resolved": "https://pkg.harness.io/pkg/ct8onj8YTdaXtKaFsYCRLg/org-progress_all-npm/npm/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+ "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
@@ -595,6 +597,13 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://pkg.harness.io/pkg/ct8onj8YTdaXtKaFsYCRLg/org-progress_all-npm/npm/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz",
@@ -947,16 +956,19 @@
"license": "MIT"
},
"node_modules/@vitejs/plugin-vue": {
- "version": "5.2.4",
- "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
- "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
+ "version": "6.0.8",
+ "resolved": "https://pkg.harness.io/pkg/ct8onj8YTdaXtKaFsYCRLg/org-progress_all-npm/npm/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz",
+ "integrity": "sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==",
"dev": true,
"license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "^1.0.1"
+ },
"engines": {
- "node": "^18.0.0 || >=20.0.0"
+ "node": "^20.19.0 || >=22.12.0"
},
"peerDependencies": {
- "vite": "^5.0.0 || ^6.0.0",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
"vue": "^3.2.25"
}
},
@@ -1472,9 +1484,9 @@
}
},
"node_modules/esbuild": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
- "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
+ "version": "0.28.1",
+ "resolved": "https://pkg.harness.io/pkg/ct8onj8YTdaXtKaFsYCRLg/org-progress_all-npm/npm/esbuild/-/esbuild-0.28.1.tgz",
+ "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"devOptional": true,
"hasInstallScript": true,
"license": "MIT",
@@ -1485,32 +1497,32 @@
"node": ">=18"
},
"optionalDependencies": {
- "@esbuild/aix-ppc64": "0.27.7",
- "@esbuild/android-arm": "0.27.7",
- "@esbuild/android-arm64": "0.27.7",
- "@esbuild/android-x64": "0.27.7",
- "@esbuild/darwin-arm64": "0.27.7",
- "@esbuild/darwin-x64": "0.27.7",
- "@esbuild/freebsd-arm64": "0.27.7",
- "@esbuild/freebsd-x64": "0.27.7",
- "@esbuild/linux-arm": "0.27.7",
- "@esbuild/linux-arm64": "0.27.7",
- "@esbuild/linux-ia32": "0.27.7",
- "@esbuild/linux-loong64": "0.27.7",
- "@esbuild/linux-mips64el": "0.27.7",
- "@esbuild/linux-ppc64": "0.27.7",
- "@esbuild/linux-riscv64": "0.27.7",
- "@esbuild/linux-s390x": "0.27.7",
- "@esbuild/linux-x64": "0.27.7",
- "@esbuild/netbsd-arm64": "0.27.7",
- "@esbuild/netbsd-x64": "0.27.7",
- "@esbuild/openbsd-arm64": "0.27.7",
- "@esbuild/openbsd-x64": "0.27.7",
- "@esbuild/openharmony-arm64": "0.27.7",
- "@esbuild/sunos-x64": "0.27.7",
- "@esbuild/win32-arm64": "0.27.7",
- "@esbuild/win32-ia32": "0.27.7",
- "@esbuild/win32-x64": "0.27.7"
+ "@esbuild/aix-ppc64": "0.28.1",
+ "@esbuild/android-arm": "0.28.1",
+ "@esbuild/android-arm64": "0.28.1",
+ "@esbuild/android-x64": "0.28.1",
+ "@esbuild/darwin-arm64": "0.28.1",
+ "@esbuild/darwin-x64": "0.28.1",
+ "@esbuild/freebsd-arm64": "0.28.1",
+ "@esbuild/freebsd-x64": "0.28.1",
+ "@esbuild/linux-arm": "0.28.1",
+ "@esbuild/linux-arm64": "0.28.1",
+ "@esbuild/linux-ia32": "0.28.1",
+ "@esbuild/linux-loong64": "0.28.1",
+ "@esbuild/linux-mips64el": "0.28.1",
+ "@esbuild/linux-ppc64": "0.28.1",
+ "@esbuild/linux-riscv64": "0.28.1",
+ "@esbuild/linux-s390x": "0.28.1",
+ "@esbuild/linux-x64": "0.28.1",
+ "@esbuild/netbsd-arm64": "0.28.1",
+ "@esbuild/netbsd-x64": "0.28.1",
+ "@esbuild/openbsd-arm64": "0.28.1",
+ "@esbuild/openbsd-x64": "0.28.1",
+ "@esbuild/openharmony-arm64": "0.28.1",
+ "@esbuild/sunos-x64": "0.28.1",
+ "@esbuild/win32-arm64": "0.28.1",
+ "@esbuild/win32-ia32": "0.28.1",
+ "@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/estree-walker": {
@@ -1681,9 +1693,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
- "version": "3.3.12",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
- "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+ "version": "3.3.18",
+ "resolved": "https://pkg.harness.io/pkg/ct8onj8YTdaXtKaFsYCRLg/org-progress_all-npm/npm/nanoid/-/nanoid-3.3.18.tgz",
+ "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"funding": [
{
"type": "github",
@@ -1768,9 +1780,9 @@
}
},
"node_modules/postcss": {
- "version": "8.5.15",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
- "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
+ "version": "8.5.23",
+ "resolved": "https://pkg.harness.io/pkg/ct8onj8YTdaXtKaFsYCRLg/org-progress_all-npm/npm/postcss/-/postcss-8.5.23.tgz",
+ "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
"funding": [
{
"type": "opencollective",
@@ -1787,7 +1799,7 @@
],
"license": "MIT",
"dependencies": {
- "nanoid": "^3.3.12",
+ "nanoid": "^3.3.16",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -1978,26 +1990,26 @@
}
},
"node_modules/uuid": {
- "version": "9.0.1",
- "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
- "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
+ "version": "11.1.1",
+ "resolved": "https://pkg.harness.io/pkg/ct8onj8YTdaXtKaFsYCRLg/org-progress_all-npm/npm/uuid/-/uuid-11.1.1.tgz",
+ "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
- "uuid": "dist/bin/uuid"
+ "uuid": "dist/esm/bin/uuid"
}
},
"node_modules/vite": {
- "version": "7.3.5",
- "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz",
- "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==",
+ "version": "7.3.6",
+ "resolved": "https://pkg.harness.io/pkg/ct8onj8YTdaXtKaFsYCRLg/org-progress_all-npm/npm/vite/-/vite-7.3.6.tgz",
+ "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
- "esbuild": "^0.27.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
"fdir": "^6.5.0",
"picomatch": "^4.0.3",
"postcss": "^8.5.6",
diff --git a/hyperforge/frontend/package.json b/hyperforge/frontend/package.json
index 61cd36f8..616b0690 100644
--- a/hyperforge/frontend/package.json
+++ b/hyperforge/frontend/package.json
@@ -14,15 +14,17 @@
"@vue-flow/core": "^1.41.8",
"@vue-flow/minimap": "^1.5.0",
"pinia": "^3.0.0",
- "uuid": "^9.0.0",
+ "uuid": "11.1.1",
"vue": "^3.4.0",
"vue-router": "^5.0.0"
},
"devDependencies": {
"@types/uuid": "^9.0.7",
- "@vitejs/plugin-vue": "^5.0.4",
+ "@vitejs/plugin-vue": "6.0.8",
+ "esbuild": "0.28.1",
+ "postcss": "8.5.23",
"typescript": "^5.3.3",
- "vite": "^7.0.0",
+ "vite": "7.3.6",
"vue-tsc": "^3.0.0"
}
-}
\ No newline at end of file
+}
diff --git a/hyperforge/frontend/src/stores/chat.ts b/hyperforge/frontend/src/stores/chat.ts
index a75e7fa5..c9cd3b70 100644
--- a/hyperforge/frontend/src/stores/chat.ts
+++ b/hyperforge/frontend/src/stores/chat.ts
@@ -5,6 +5,7 @@ import { connectChat } from '@/api/chatWs'
import type { ChatSocket } from '@/api/chatWs'
import { AnswerOperation } from '@/types/arag'
import type { AragAnswer, AragContext, AragStep, Feedback } from '@/types/arag'
+import { safeOAuthUrl } from '@/utils/oauthUrl'
export interface ChatMessage {
id: string
@@ -186,7 +187,7 @@ function _applyChunk(msg: ChatMessage, chunk: AragAnswer) {
// from a WS message handler is reliably blocked by browsers since
// there's no preceding user gesture. The agent resumes on its own
// once OAuth completes, so we don't pause streaming here.
- msg.pendingOAuthUrl = chunk.oauth.oauth_url
+ msg.pendingOAuthUrl = safeOAuthUrl(chunk.oauth.oauth_url)
}
}
if (chunk.operation === AnswerOperation.ERROR && chunk.exception) {
diff --git a/hyperforge/frontend/src/utils/oauthUrl.ts b/hyperforge/frontend/src/utils/oauthUrl.ts
new file mode 100644
index 00000000..3710789a
--- /dev/null
+++ b/hyperforge/frontend/src/utils/oauthUrl.ts
@@ -0,0 +1,11 @@
+export function safeOAuthUrl(value: string): string | undefined {
+ try {
+ const url = new URL(value)
+ if (url.protocol !== 'https:' || !url.hostname || url.username || url.password) {
+ return undefined
+ }
+ return url.href
+ } catch {
+ return undefined
+ }
+}
diff --git a/hyperforge/pyproject.toml b/hyperforge/pyproject.toml
index b366cbd0..d5ab7427 100644
--- a/hyperforge/pyproject.toml
+++ b/hyperforge/pyproject.toml
@@ -28,7 +28,8 @@ dependencies = [
"prometheus_client",
"mrflagly>=0.2.11",
"aiodns",
- "httpx",
+ "httpx>=0.28.1,<0.29",
+ "httpcore>=1.0.9,<1.1",
"jinja2",
"databases[asyncpg]",
"sqlalchemy",
diff --git a/hyperforge/src/hyperforge/api/app.py b/hyperforge/src/hyperforge/api/app.py
index 8fcddf5c..9ac2f7a3 100644
--- a/hyperforge/src/hyperforge/api/app.py
+++ b/hyperforge/src/hyperforge/api/app.py
@@ -16,7 +16,7 @@
from starlette.middleware.authentication import AuthenticationMiddleware
from starlette.responses import PlainTextResponse
-from hyperforge.api import SERVICE_NAME, internal, logger, v1
+from hyperforge.api import SERVICE_NAME, logger, v1
from hyperforge.api.authentication import RaoAuthenticationBackend
from hyperforge.api.logging import set_sentry
from hyperforge.api.settings import Settings
@@ -74,7 +74,6 @@ async def lifespan(app: "HTTPApplication"):
self.settings = settings
self.data_manager_settings = data_manager_settings
self._agents_cfg = {}
- self.include_router(internal.router)
self.include_router(v1.router)
self.include_router(router)
self.add_middleware(
diff --git a/hyperforge/src/hyperforge/api/internal/__init__.py b/hyperforge/src/hyperforge/api/internal/__init__.py
deleted file mode 100644
index f62ab884..00000000
--- a/hyperforge/src/hyperforge/api/internal/__init__.py
+++ /dev/null
@@ -1,4 +0,0 @@
-from . import inspect
-from .router import router
-
-__all__ = ["inspect", "router"]
diff --git a/hyperforge/src/hyperforge/api/internal/inspect.py b/hyperforge/src/hyperforge/api/internal/inspect.py
deleted file mode 100644
index 0f04171e..00000000
--- a/hyperforge/src/hyperforge/api/internal/inspect.py
+++ /dev/null
@@ -1,30 +0,0 @@
-from typing import TYPE_CHECKING
-
-from starlette.requests import Request
-
-from hyperforge.api.internal.router import router
-from hyperforge.api.models import InspectData
-from hyperforge.db.agents import AgentManager
-
-if TYPE_CHECKING:
- from hyperforge.api.app import HTTPApplication
-
-
-@router.get(
- "/api/internal/v1/agent/{kbid}",
- status_code=200,
- description="Report task is done",
- tags=["Task"],
- include_in_schema=False,
-)
-async def inspect_agent_info(request: Request, kbid: str, account: str) -> InspectData:
- app: HTTPApplication = request.app
- agent_manager: AgentManager = app.agent_manager
-
- return InspectData(
- contexts=await agent_manager.get_context(account, kbid),
- driver=await agent_manager.get_drivers(account, kbid),
- postprocess=await agent_manager.get_postprocess(account, kbid),
- preprocess=await agent_manager.get_preprocess(account, kbid),
- rules=await agent_manager.get_rules(account, kbid),
- )
diff --git a/hyperforge/src/hyperforge/api/internal/router.py b/hyperforge/src/hyperforge/api/internal/router.py
deleted file mode 100644
index 8009c643..00000000
--- a/hyperforge/src/hyperforge/api/internal/router.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from fastapi.routing import APIRouter
-
-router = APIRouter()
diff --git a/hyperforge/src/hyperforge/api/settings.py b/hyperforge/src/hyperforge/api/settings.py
index 15b9ad1a..2ff2d3ca 100644
--- a/hyperforge/src/hyperforge/api/settings.py
+++ b/hyperforge/src/hyperforge/api/settings.py
@@ -38,3 +38,5 @@ class Settings(BaseSettings):
hydra_scopes_supported: list[str] = ["offline_access", "openid"]
mcp_force_https_metadata: bool = True
auth_success_logo_url: Optional[str] = None
+ mcp_max_request_bytes: int = 1024 * 1024
+ mcp_max_response_bytes: int = 4 * 1024 * 1024
diff --git a/hyperforge/src/hyperforge/api/v1/mcp_interaction.py b/hyperforge/src/hyperforge/api/v1/mcp_interaction.py
index 521713ba..9365fd38 100644
--- a/hyperforge/src/hyperforge/api/v1/mcp_interaction.py
+++ b/hyperforge/src/hyperforge/api/v1/mcp_interaction.py
@@ -4,7 +4,7 @@
from typing import TYPE_CHECKING, Any, Iterable, Sequence
import anyio
-from fastapi import Header
+from fastapi import Header, HTTPException
from mcp.server.fastmcp.exceptions import ResourceError
from mcp.server.lowlevel.helper_types import ReadResourceContents
from mcp.server.lowlevel.server import Server as MCPServer
@@ -310,6 +310,25 @@ async def interaction_mcp_handler(
x_stf_account_type: str = Header(..., include_in_schema=False),
):
app: HTTPApplication = request.app
+ runtime_settings = getattr(app, "_standalone_settings", app.settings)
+ max_request_bytes = runtime_settings.mcp_max_request_bytes
+ max_response_bytes = runtime_settings.mcp_max_response_bytes
+ content_length = request.headers.get("content-length")
+ if content_length is not None:
+ try:
+ if int(content_length) > max_request_bytes:
+ raise HTTPException(status_code=413, detail="MCP request is too large")
+ except ValueError:
+ raise HTTPException(
+ status_code=400, detail="Invalid Content-Length"
+ ) from None
+
+ body_bytes = bytearray()
+ async for chunk in request.stream():
+ if len(body_bytes) + len(chunk) > max_request_bytes:
+ raise HTTPException(status_code=413, detail="MCP request is too large")
+ body_bytes.extend(chunk)
+
agent_manager: AgentManager = request.app.agent_manager
request._headers._list.append((MCP_SESSION_ID_HEADER.encode(), session.encode()))
@@ -380,16 +399,26 @@ async def run_stateless_server(
response_status = 200
response_headers: dict[str, str] = {}
body_chunks: list[bytes] = []
+ response_bytes = 0
+ response_too_large = False
async def intercepting_send(message: MutableMapping[str, Any]) -> None:
- nonlocal response_status
+ nonlocal response_status, response_bytes, response_too_large
if message["type"] == "http.response.start":
response_status = message["status"]
response_headers.update(
{k.decode(): v.decode() for k, v in message.get("headers", [])}
)
elif message["type"] == "http.response.body":
- body_chunks.append(message.get("body", b""))
+ chunk = message.get("body", b"")
+ if not chunk or response_too_large:
+ return
+ response_bytes += len(chunk)
+ if response_bytes > max_response_bytes:
+ response_too_large = True
+ body_chunks.clear()
+ return
+ body_chunks.append(chunk)
# Pre-read the body before passing to the MCP transport.
# Fix for FastAPI/Starlette 1.x
@@ -397,14 +426,17 @@ async def intercepting_send(message: MutableMapping[str, Any]) -> None:
# TODO: consider rewriting this handler to use the official StreamableHTTPSessionManager
# This does not happen in arag due to older versions of FastAPI/Starlette.
- body_bytes = await request.body()
body_sent = False
async def patched_receive():
nonlocal body_sent
if not body_sent:
body_sent = True
- return {"type": "http.request", "body": body_bytes, "more_body": False}
+ return {
+ "type": "http.request",
+ "body": bytes(body_bytes),
+ "more_body": False,
+ }
while True:
msg = await request._receive()
if msg["type"] == "http.disconnect":
@@ -422,6 +454,9 @@ async def patched_receive():
# Terminate the transport after the request is handled
await http_transport.terminate()
+ if response_too_large:
+ return Response(content="MCP response is too large", status_code=502)
+
return Response(
content=b"".join(body_chunks),
status_code=response_status,
diff --git a/hyperforge/src/hyperforge/interaction.py b/hyperforge/src/hyperforge/interaction.py
index e03bf6cd..181ad7e8 100644
--- a/hyperforge/src/hyperforge/interaction.py
+++ b/hyperforge/src/hyperforge/interaction.py
@@ -2,7 +2,7 @@
from enum import Enum
from typing import Any, Dict, Optional
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, field_validator
from hyperforge.models import (
Answer,
@@ -11,6 +11,7 @@
Step,
Visualization,
)
+from hyperforge.utils.http import validate_public_http_url
# Models used between client and API for itneraction requests/responses/streams
@@ -66,6 +67,11 @@ class Provider(Enum):
class OAuthAuthenticateURL(BaseModel):
oauth_url: str
+ @field_validator("oauth_url")
+ @classmethod
+ def validate_oauth_url(cls, value: str) -> str:
+ return validate_public_http_url(value, https_only=True)
+
class OAuthFeedbackReturnSchema(BaseModel):
existing_credentials: Optional[Dict[str, Dict[str, str]]] = None
diff --git a/hyperforge/src/hyperforge/manager.py b/hyperforge/src/hyperforge/manager.py
index 9ad0f716..4ad85dc4 100644
--- a/hyperforge/src/hyperforge/manager.py
+++ b/hyperforge/src/hyperforge/manager.py
@@ -515,6 +515,55 @@ async def execute_json(
output_tokens,
)
+ async def execute_json_reasoning(
+ self,
+ prompt: str,
+ user_id: str,
+ schema: Dict[str, Any],
+ model: ModelParam,
+ images: Dict[str, Image] = {},
+ system: Optional[str] = None,
+ max_tokens: int = 8192,
+ tracking: TrackingInfo | None = None,
+ ) -> Tuple[Dict[str, Any], float, float, str | None]:
+ try:
+ resp = await self.nua.generate(
+ body=ChatModel(
+ user_id=user_id,
+ question="",
+ user_prompt=UserPrompt(prompt=prompt),
+ generative_model=_resolve_model_id(model),
+ reasoning=build_reasoning(model),
+ format_prompt=False,
+ query_context_images=images,
+ json_schema=schema,
+ system=system,
+ max_tokens=max_tokens,
+ citations=False,
+ ),
+ extra_headers=self._build_extra_headers(tracking),
+ )
+
+ except ValidationError as e:
+ convert_errors(e)
+ raise
+
+ if resp.object is None:
+ raise Exception("No object")
+
+ if resp.consumption is None or resp.consumption.normalized_tokens is None:
+ input_tokens = 0.0
+ output_tokens = 0.0
+ else:
+ input_tokens = resp.consumption.normalized_tokens.input
+ output_tokens = resp.consumption.normalized_tokens.output
+ return (
+ resp.object,
+ input_tokens,
+ output_tokens,
+ resp.reasoning,
+ )
+
async def execute_json_citation(
self,
question: str,
diff --git a/hyperforge/src/hyperforge/standalone/app.py b/hyperforge/src/hyperforge/standalone/app.py
index e1954b62..104c24c0 100644
--- a/hyperforge/src/hyperforge/standalone/app.py
+++ b/hyperforge/src/hyperforge/standalone/app.py
@@ -6,6 +6,8 @@
NucliaDB required.
"""
+import base64
+import secrets
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any, Tuple
@@ -110,13 +112,20 @@ async def health_alive():
class StandaloneAuthBackend(AuthenticationBackend):
"""Open standalone auth, with optional OAuth protection for MCP endpoints."""
- def __init__(self, agents_cfg: dict[str, Any]) -> None:
+ def __init__(
+ self, agents_cfg: dict[str, Any], username: str, password: str | None
+ ) -> None:
self._agents_cfg = agents_cfg
+ self._ui_username = username
+ self._ui_password = password
self._jwks_cache = JWKSCache()
async def authenticate(
self, conn: HTTPConnection
) -> tuple[AuthCredentials, BaseUser] | None:
+ if conn.url.path.startswith("/api/v1/ui/"):
+ self._authenticate_ui(conn)
+
agent_id = self._mcp_agent_id(conn)
if agent_id is not None:
auth_config = get_enabled_mcp_auth(self._agents_cfg, agent_id)
@@ -148,6 +157,28 @@ async def authenticate(
]
return _ALL_ROLES, User(username="standalone")
+ def _authenticate_ui(self, conn: HTTPConnection) -> None:
+ if self._ui_password is None:
+ return
+
+ authorization = conn.headers.get("authorization")
+ if authorization is None:
+ raise AuthenticationError("Missing administrator credentials")
+ scheme, _, encoded = authorization.partition(" ")
+ if scheme.lower() != "basic" or not encoded:
+ raise AuthenticationError("Invalid administrator credentials")
+ try:
+ username, separator, password = (
+ base64.b64decode(encoded, validate=True).decode("utf-8").partition(":")
+ )
+ except (ValueError, UnicodeDecodeError):
+ raise AuthenticationError("Invalid administrator credentials") from None
+ if not separator or not (
+ secrets.compare_digest(username, self._ui_username)
+ and secrets.compare_digest(password, self._ui_password)
+ ):
+ raise AuthenticationError("Invalid administrator credentials")
+
def _mcp_agent_id(self, conn: HTTPConnection) -> str | None:
parts = conn.url.path.strip("/").split("/")
if (
@@ -163,6 +194,12 @@ def _mcp_agent_id(self, conn: HTTPConnection) -> str | None:
def standalone_auth_error(
conn: HTTPConnection, exc: AuthenticationError
) -> PlainTextResponse:
+ if conn.url.path.startswith("/api/v1/ui/"):
+ return PlainTextResponse(
+ str(exc) or "Unauthorized",
+ status_code=401,
+ headers={"WWW-Authenticate": 'Basic realm="Hyperforge administration"'},
+ )
authenticate = "Bearer"
metadata_url = _mcp_resource_metadata_url(conn)
if metadata_url is not None:
@@ -239,13 +276,14 @@ async def lifespan(app: "StandaloneApplication"):
self.include_router(v1.mcp_interaction.router)
self.include_router(v1_oauth.router)
self.include_router(router)
- self.include_router(ui_router)
+ if settings.ui_admin_password is not None:
+ self.include_router(ui_router)
# Serve the built frontend SPA if the dist directory exists.
# In development the Vite dev server runs separately (proxied to :8080).
# app.py lives at: arag/src/hyperforge.standalone/app.py
# three .parent steps → arag/
- _frontend_dist = Path(__file__).parent / "static"
+ _frontend_dist = Path(settings.static_folder).resolve()
if _frontend_dist.is_dir():
self.mount(
"/",
@@ -255,7 +293,13 @@ async def lifespan(app: "StandaloneApplication"):
self.add_middleware(
AuthenticationMiddleware,
- backend=StandaloneAuthBackend(agents_cfg),
+ backend=StandaloneAuthBackend(
+ agents_cfg,
+ settings.ui_admin_username,
+ settings.ui_admin_password.get_secret_value()
+ if settings.ui_admin_password is not None
+ else None,
+ ),
on_error=standalone_auth_error,
)
self.add_middleware(
diff --git a/hyperforge/src/hyperforge/standalone/oauth.py b/hyperforge/src/hyperforge/standalone/oauth.py
index 34500c67..d8c5b37b 100644
--- a/hyperforge/src/hyperforge/standalone/oauth.py
+++ b/hyperforge/src/hyperforge/standalone/oauth.py
@@ -4,12 +4,16 @@
from dataclasses import dataclass, field
from typing import Any
-import httpx
from cryptography.hazmat.primitives.asymmetric import padding, rsa
from cryptography.hazmat.primitives.hashes import SHA256, SHA384, SHA512
from starlette.authentication import AuthenticationError
from hyperforge.standalone.config import StandAloneAgentConfig, StandaloneMCPAuthConfig
+from hyperforge.utils.http import (
+ read_limited_response,
+ safe_http_client,
+ validate_public_http_url,
+)
_HASHES = {
"RS256": SHA256(),
@@ -33,6 +37,7 @@ class JWKSCache:
_values: dict[str, tuple[float, dict[str, Any]]] = field(default_factory=dict)
async def get(self, url: str) -> dict[str, Any]:
+ validate_public_http_url(url, https_only=True)
now = time.time()
cached = self._values.get(url)
if cached is not None:
@@ -40,10 +45,15 @@ async def get(self, url: str) -> dict[str, Any]:
if expires_at > now:
return jwks
- async with httpx.AsyncClient(timeout=10) as client:
- response = await client.get(url)
- response.raise_for_status()
- jwks = response.json()
+ async with safe_http_client(timeout=10) as client:
+ request = client.build_request("GET", url)
+ response = await client.send(request, stream=True, follow_redirects=True)
+ try:
+ response.raise_for_status()
+ content = await read_limited_response(response, 1024 * 1024)
+ finally:
+ await response.aclose()
+ jwks = json.loads(content)
self._values[url] = (now + self.ttl_seconds, jwks)
return jwks
diff --git a/hyperforge/src/hyperforge/standalone/settings.py b/hyperforge/src/hyperforge/standalone/settings.py
index 583e95c7..30615175 100644
--- a/hyperforge/src/hyperforge/standalone/settings.py
+++ b/hyperforge/src/hyperforge/standalone/settings.py
@@ -13,11 +13,15 @@
from pathlib import Path
from typing import Optional
-from pydantic import AliasChoices, Field
-from pydantic_settings import BaseSettings
+from pydantic import AliasChoices, Field, SecretStr
+from pydantic_settings import BaseSettings, SettingsConfigDict
class StandaloneSettings(BaseSettings):
+ model_config = SettingsConfigDict(
+ env_file=".env", env_file_encoding="utf-8", extra="ignore"
+ )
+
# ------------------------------------------------------------------
# Agent config file
# ------------------------------------------------------------------
@@ -116,15 +120,25 @@ class StandaloneSettings(BaseSettings):
)
in_memory_cache_size: int = 3000
+ mcp_max_request_bytes: int = Field(default=1024 * 1024, ge=1)
+ mcp_max_response_bytes: int = Field(default=4 * 1024 * 1024, ge=1)
cors_allow_origin: list[str] = Field(
- default_factory=lambda: ["*"],
+ default_factory=list,
description=(
"List of allowed origins for CORS. Defaults to ['*'] which allows all origins. "
"In production, it's recommended to set this to the specific origins that should be allowed."
),
)
+ ui_admin_username: str = Field(
+ default="admin", description="Username for the standalone configuration UI."
+ )
+ ui_admin_password: SecretStr | None = Field(
+ default=None,
+ description="Password that enables and protects the standalone configuration UI.",
+ )
+
load_modules: list[str] = []
# ------------------------------------------------------------------
# Pluggable module classes
@@ -146,3 +160,8 @@ class StandaloneSettings(BaseSettings):
default="hyperforge.standalone.app.StandaloneApplication",
description="Dotted-name of the StandaloneApplication class to use.",
)
+
+ static_folder: str = Field(
+ default=str(Path(__file__).parent.parent / "static"),
+ description="Path to the static folder for the frontend.",
+ )
diff --git a/hyperforge/src/hyperforge/utils/__init__.py b/hyperforge/src/hyperforge/utils/__init__.py
index 58a5eb9f..b017db9b 100644
--- a/hyperforge/src/hyperforge/utils/__init__.py
+++ b/hyperforge/src/hyperforge/utils/__init__.py
@@ -1,11 +1,8 @@
-import ipaddress
-import socket
import urllib.parse
from collections.abc import Iterable
from enum import Enum
from typing import Any, Dict, Optional, Tuple
-import aiodns
from nuclia_models.predict.generative_responses import GenerativeFullResponse
@@ -20,46 +17,19 @@ def iterate_tools_resp(
yield tool.function.name, used_params
-def sync_check_dns(url: str) -> Optional[str]:
+def validate_url(url: str) -> Optional[str]:
parsed_url = urllib.parse.urlparse(url)
- error = None
- try:
- if parsed_url.hostname is None:
- hostname = parsed_url.path
- else:
- hostname = parsed_url.hostname
- addr = socket.gethostbyname_ex(hostname)
- for addres in addr[2]:
- if ipaddress.ip_address(addres).is_private:
- error = "Its a private address"
- except aiodns.error.DNSError:
- error = "Could not find this URL"
- return error
+ if parsed_url.scheme.lower() not in {"http", "https"}:
+ return "URL must use HTTP or HTTPS"
+ if parsed_url.hostname is None:
+ return "URL must include a hostname"
+ if parsed_url.username is not None or parsed_url.password is not None:
+ return "Credentials are not allowed in URLs"
+ return None
def sync_dns_validation(url: str) -> str:
- error = sync_check_dns(url)
- if error is not None:
- raise ValueError(error)
- return url
-
-
-async def check_dns(url: str) -> str:
- parsed_url = urllib.parse.urlparse(url)
- resolver = aiodns.DNSResolver()
- error = None
- try:
- if parsed_url.hostname is None:
- hostname = parsed_url.path
- else:
- hostname = parsed_url.hostname
- addr = await resolver.gethostbyname(hostname, socket.AF_INET)
- for addres in addr.addresses:
- if ipaddress.ip_address(addres).is_private:
- error = "Its a private address"
- except aiodns.error.DNSError:
- error = "Could not find this URL"
-
+ error = validate_url(url)
if error is not None:
raise ValueError(error)
return url
diff --git a/hyperforge/src/hyperforge/utils/http.py b/hyperforge/src/hyperforge/utils/http.py
index 5735e014..c79edbbc 100644
--- a/hyperforge/src/hyperforge/utils/http.py
+++ b/hyperforge/src/hyperforge/utils/http.py
@@ -1,48 +1,135 @@
import ipaddress
import socket
+from collections.abc import AsyncIterator, Iterable
+from typing import Any
-import aiodns
-from httpx import AsyncClient, AsyncHTTPTransport
+import anyio
+import httpcore
+from httpcore._backends.base import SOCKET_OPTION, AsyncNetworkStream
+from httpx import AsyncClient, AsyncHTTPTransport, Request, Response
class PrivateUrlError(Exception):
pass
+class ResponseTooLargeError(Exception):
+ pass
+
+
+def validate_public_http_url(url: str, *, https_only: bool = False) -> str:
+ from urllib.parse import urlsplit
+
+ parsed = urlsplit(url)
+ allowed_schemes = {"https"} if https_only else {"http", "https"}
+ if parsed.scheme.lower() not in allowed_schemes or parsed.hostname is None:
+ raise ValueError("URL must use an allowed HTTP scheme and include a hostname")
+ if parsed.username is not None or parsed.password is not None:
+ raise ValueError("Credentials are not allowed in URLs")
+ return url
+
+
+async def _resolve_public_addresses(host: str, port: int) -> list[str]:
+ try:
+ addresses = await anyio.getaddrinfo(
+ host,
+ port,
+ family=socket.AF_UNSPEC,
+ type=socket.SOCK_STREAM,
+ )
+ except OSError as exc:
+ raise PrivateUrlError("Could not resolve hostname") from exc
+
+ resolved = list(dict.fromkeys(address[4][0] for address in addresses))
+ if not resolved or any(
+ not ipaddress.ip_address(address.split("%", 1)[0]).is_global
+ for address in resolved
+ ):
+ raise PrivateUrlError("Cannot access non-public network resources")
+ return resolved
+
+
+class SafeNetworkBackend(httpcore.AsyncNetworkBackend):
+ def __init__(self) -> None:
+ self._backend = httpcore.AsyncConnectionPool()._network_backend
+
+ async def connect_tcp(
+ self,
+ host: str,
+ port: int,
+ timeout: float | None = None,
+ local_address: str | None = None,
+ socket_options: Iterable[SOCKET_OPTION] | None = None,
+ ) -> AsyncNetworkStream:
+ addresses = await _resolve_public_addresses(host, port)
+ last_error: Exception | None = None
+ for address in addresses:
+ try:
+ return await self._backend.connect_tcp(
+ address,
+ port,
+ timeout=timeout,
+ local_address=local_address,
+ socket_options=socket_options,
+ )
+ except Exception as exc:
+ last_error = exc
+ assert last_error is not None
+ raise last_error
+
+ async def connect_unix_socket(
+ self,
+ path: str,
+ timeout: float | None = None,
+ socket_options: Iterable[SOCKET_OPTION] | None = None,
+ ) -> AsyncNetworkStream:
+ raise PrivateUrlError("Unix sockets are not allowed")
+
+ async def sleep(self, seconds: float) -> None:
+ await self._backend.sleep(seconds)
+
+
class SafeTransport(AsyncHTTPTransport):
- def __init__(self, *args, **kwargs):
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
+ self._pool._network_backend = SafeNetworkBackend() # type: ignore
+
+ async def handle_async_request(self, request: Request) -> Response:
+ validate_public_http_url(str(request.url))
+ return await super().handle_async_request(request)
+
- @staticmethod
- async def is_private_address(hostname: str) -> bool:
- resolver = aiodns.DNSResolver()
- addr = await resolver.gethostbyname(hostname, socket.AF_INET)
- for address in addr.addresses:
- if ipaddress.ip_address(address).is_private:
- return True
- if (
- not ipaddress.ip_address(address).is_private
- and not ipaddress.ip_address(address).is_global
- ):
- # Matches shared address space (100.64.0.0/10 range) that should be non-routeable and
- # not be used for public internet. Let's consider it internal
- return True
- return False
-
- async def handle_async_request(self, request):
- url = request.url
+async def read_limited_response(response: Response, max_bytes: int) -> bytes:
+ content_length = response.headers.get("content-length")
+ if content_length is not None:
try:
- hostname = url.host if url.host is not None else url.path
- if await self.is_private_address(hostname):
- raise PrivateUrlError("Cannot access private network resources")
- except aiodns.error.DNSError:
- raise PrivateUrlError("Could not lookup hostname")
+ if int(content_length) > max_bytes:
+ raise ResponseTooLargeError("HTTP response exceeds configured limit")
+ except ValueError:
+ pass
- return await super().handle_async_request(request)
+ content = bytearray()
+ async for chunk in response.aiter_bytes():
+ if len(content) + len(chunk) > max_bytes:
+ raise ResponseTooLargeError("HTTP response exceeds configured limit")
+ content.extend(chunk)
+ return bytes(content)
+
+
+async def iter_limited_bytes(
+ chunks: AsyncIterator[bytes], max_bytes: int
+) -> AsyncIterator[bytes]:
+ total = 0
+ async for chunk in chunks:
+ total += len(chunk)
+ if total > max_bytes:
+ raise ResponseTooLargeError("HTTP response exceeds configured limit")
+ yield chunk
-def safe_http_client(timeout=30, transport_verify: bool = True):
+def safe_http_client(timeout: float = 30, transport_verify: bool = True) -> AsyncClient:
return AsyncClient(
timeout=timeout,
transport=SafeTransport(verify=transport_verify),
+ trust_env=False,
)
diff --git a/hyperforge/tests/standalone/test_standalone.py b/hyperforge/tests/standalone/test_standalone.py
index f758e99b..9721ff28 100644
--- a/hyperforge/tests/standalone/test_standalone.py
+++ b/hyperforge/tests/standalone/test_standalone.py
@@ -1,5 +1,6 @@
import json
import socket
+from base64 import b64encode
from pathlib import Path
from typing import Any, Optional
from unittest.mock import patch
@@ -12,6 +13,7 @@
from mcp import ClientSession
from mcp.client.streamable_http import streamable_http_client
from mcp.types import TextContent
+from pydantic import SecretStr
from websockets.asyncio.client import connect
from hyperforge.api.models import (
@@ -210,6 +212,45 @@ async def test_standalone_http_post(standalone_client: AsyncClient):
assert "42" in answer_msgs[-1].answer
+async def test_standalone_ui_is_disabled_without_password(
+ standalone_client: AsyncClient,
+):
+ response = await standalone_client.get("/api/v1/ui/config")
+ assert response.status_code == 404
+
+
+async def test_standalone_ui_requires_admin_credentials(local_agents_config):
+ settings = STANDALONE_SETTINGS.model_copy(
+ update={"ui_admin_password": SecretStr("secret-password")}
+ )
+ app = StandaloneApplication(local_agents_config, settings)
+ with patch("hyperforge.server.session.get_state", return_value=_mock_state):
+ async with (
+ app.router.lifespan_context(app),
+ AsyncClient(
+ transport=ASGITransport(app=app), base_url="http://test"
+ ) as client,
+ ):
+ response = await client.get("/api/v1/ui/config")
+ assert response.status_code == 401
+ assert response.headers["www-authenticate"].startswith("Basic ")
+
+ response = await client.get(
+ "/api/v1/ui/config",
+ headers={
+ "Authorization": "Basic "
+ + b64encode(b"admin:secret-password").decode()
+ },
+ )
+ assert response.status_code == 200
+
+
+async def test_standalone_security_defaults():
+ settings = StandaloneSettings()
+ assert settings.host == "0.0.0.0"
+ assert settings.cors_allow_origin == []
+
+
async def test_standalone_http_post_unknown_agent(standalone_client: AsyncClient):
"""Asking a non-existent agent_id should return an error operation, not a 500."""
async with standalone_client.stream(
diff --git a/hyperforge/tests/unit/test_safe_http.py b/hyperforge/tests/unit/test_safe_http.py
new file mode 100644
index 00000000..8e573a9c
--- /dev/null
+++ b/hyperforge/tests/unit/test_safe_http.py
@@ -0,0 +1,52 @@
+import socket
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from hyperforge.utils.http import (
+ PrivateUrlError,
+ ResponseTooLargeError,
+ _resolve_public_addresses,
+ read_limited_response,
+ validate_public_http_url,
+)
+
+
+@pytest.mark.parametrize(
+ "url",
+ ["file:///etc/passwd", "http://user:pass@example.com", "/relative"],
+)
+def test_validate_public_http_url_rejects_unsafe_urls(url: str):
+ with pytest.raises(ValueError):
+ validate_public_http_url(url)
+
+
+def test_validate_public_http_url_requires_https_when_requested():
+ with pytest.raises(ValueError):
+ validate_public_http_url("http://example.com", https_only=True)
+ assert (
+ validate_public_http_url("https://example.com", https_only=True)
+ == "https://example.com"
+ )
+
+
+@pytest.mark.asyncio
+async def test_resolver_rejects_any_non_public_address():
+ answers = [
+ (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 443)),
+ (socket.AF_INET6, socket.SOCK_STREAM, 6, "", ("::1", 443, 0, 0)),
+ ]
+ with (
+ patch("anyio.getaddrinfo", AsyncMock(return_value=answers)),
+ pytest.raises(PrivateUrlError),
+ ):
+ await _resolve_public_addresses("example.com", 443)
+
+
+@pytest.mark.asyncio
+async def test_read_limited_response_rejects_large_body():
+ import httpx
+
+ response = httpx.Response(200, content=b"12345")
+ with pytest.raises(ResponseTooLargeError):
+ await read_limited_response(response, 4)
diff --git a/uv.lock b/uv.lock
index 56118033..80f1cab8 100644
--- a/uv.lock
+++ b/uv.lock
@@ -941,6 +941,7 @@ dependencies = [
{ name = "cryptography" },
{ name = "databases", extra = ["asyncpg"] },
{ name = "fastapi" },
+ { name = "httpcore" },
{ name = "httpx" },
{ name = "hyperforge-restricted" },
{ name = "jinja2" },
@@ -990,7 +991,8 @@ requires-dist = [
{ name = "cryptography" },
{ name = "databases", extras = ["asyncpg"] },
{ name = "fastapi" },
- { name = "httpx" },
+ { name = "httpcore", specifier = ">=1.0.9,<1.1" },
+ { name = "httpx", specifier = ">=0.28.1,<0.29" },
{ name = "hyperforge-restricted", editable = "agents/restricted" },
{ name = "jinja2" },
{ name = "lru-dict" },