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" },