Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion patch_file.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import re

with open("src/nodetool/api/file.py", "r") as f:
with open("src/nodetool/api/file.py") as f:
content = f.read()

# Fix 1: Add check in list_files
Expand Down
2 changes: 1 addition & 1 deletion patch_tests.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import re

with open("tests/api/test_file_api.py", "r") as f:
with open("tests/api/test_file_api.py") as f:
content = f.read()

# Add mock import if not present
Expand Down
1 change: 0 additions & 1 deletion scripts/export_parity_snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
import types
import typing


# ── Type Mapping ──────────────────────────────────────────────────────


Expand Down
8 changes: 8 additions & 0 deletions src/nodetool/agents/serp_providers/apify_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,14 @@ async def search_duckduckgo(
"""
return {"error": "DuckDuckGo search not supported by Apify provider"}

async def search_raw(
self, engine: str, params: dict[str, Any]
) -> dict[str, Any] | ErrorResponse:
"""
Generic search method. Not currently supported by ApifyProvider.
"""
return {"error": "Raw engine search is not supported by ApifyProvider."}

async def close(self) -> None:
"""Clean up any resources (e.g., close HTTP clients)."""
# Only close if we created the client ourselves (not from ResourceScope)
Expand Down
8 changes: 8 additions & 0 deletions src/nodetool/agents/serp_providers/data_for_seo_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,14 @@ async def search_duckduckgo(
"""
return {"error": "DuckDuckGo search not supported by DataForSEO provider"}

async def search_raw(
self, engine: str, params: dict[str, Any]
) -> dict[str, Any] | ErrorResponse:
"""
Generic search method. Not currently supported by DataForSEOProvider.
"""
return {"error": "Raw engine search is not supported by DataForSEO provider."}

async def close(self) -> None:
"""Closes the HTTP client."""
# Only close if we created the client ourselves (not from ResourceScope)
Expand Down
4 changes: 2 additions & 2 deletions src/nodetool/api/file.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ def ensure_within_root(root: str, path: str, error_message: str) -> str:
"""
Ensure the given path is contained within root, normalizing for case-insensitive filesystems.
"""
normalized_root = os.path.normcase(os.path.abspath(root))
normalized_path = os.path.normcase(os.path.abspath(path))
normalized_root = os.path.normcase(os.path.realpath(root))
normalized_path = os.path.normcase(os.path.realpath(path))
root_prefix = normalized_root if normalized_root.endswith(os.sep) else normalized_root + os.sep
if normalized_path != normalized_root and not normalized_path.startswith(root_prefix):
raise HTTPException(status_code=403, detail=error_message)
Expand Down
4 changes: 2 additions & 2 deletions src/nodetool/api/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -317,8 +317,8 @@ def ensure_within_root(root: str, path: str, error_message: str) -> str:
"""
Ensure the given path is contained within root, normalizing for case-insensitive filesystems.
"""
normalized_root = os.path.normcase(os.path.abspath(root))
normalized_path = os.path.normcase(os.path.abspath(path))
normalized_root = os.path.normcase(os.path.realpath(root))
normalized_path = os.path.normcase(os.path.realpath(path))
root_prefix = normalized_root if normalized_root.endswith(os.sep) else normalized_root + os.sep
if normalized_path != normalized_root and not normalized_path.startswith(root_prefix):
raise HTTPException(status_code=403, detail=error_message)
Expand Down
8 changes: 6 additions & 2 deletions src/nodetool/io/path_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,14 @@ def resolve_workspace_path(workspace_dir: str | None, path: str) -> str:
# Join the workspace directory with the potentially cleaned relative path
abs_path = os.path.abspath(os.path.join(workspace_dir, relative_path))

# Resolve symlinks to prevent symlink traversal attacks
real_path = os.path.realpath(abs_path)
real_workspace_dir = os.path.realpath(workspace_dir)

# Final check: ensure the resolved path is still within the workspace directory
# Use commonpath for robustness across OS (prevents partial path traversal)
common_path = os.path.commonpath([os.path.abspath(workspace_dir), abs_path])
if os.path.abspath(workspace_dir) != common_path:
common_path = os.path.commonpath([real_workspace_dir, real_path])
if real_workspace_dir != common_path:
log.error(
f"Resolved path '{abs_path}' is outside the workspace directory '{workspace_dir}'. Original path: '{path}'"
)
Expand Down
29 changes: 15 additions & 14 deletions src/nodetool/providers/huggingface_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -1181,27 +1181,27 @@ async def automatic_speech_recognition(

try:
client = self.get_client()

# Prepare optional parameters
# Note: AsyncInferenceClient.automatic_speech_recognition parameters might vary by version
# but generally accepts the binary audio data
# We call the method directly.

# We call the method directly.
# Based on docs: https://huggingface.co/docs/inference-providers/en/tasks/automatic-speech-recognition
# The result object typically contains 'text' field or is a JSON object.

result = await client.automatic_speech_recognition(
audio,
model=model,
)

log.debug("HuggingFace ASR API call successful")

# If it's a dict or object (parsed JSON)
if isinstance(result, dict):
if "text" in result:
return result["text"]

return str(result)

except Exception as e:
Expand Down Expand Up @@ -1268,23 +1268,23 @@ async def text_to_image(

if params.negative_prompt:
parameters["negative_prompt"] = params.negative_prompt

if params.height:
parameters["height"] = params.height

if params.width:
parameters["width"] = params.width

if params.num_inference_steps:
parameters["num_inference_steps"] = params.num_inference_steps

if params.guidance_scale:
parameters["guidance_scale"] = params.guidance_scale

# Preserve original logic for seed: ignore if 0 or None
if params.seed and params.seed >= 0:
parameters["seed"] = params.seed

if hasattr(params, "scheduler") and params.scheduler:
parameters["scheduler"] = params.scheduler

Expand Down Expand Up @@ -1327,6 +1327,7 @@ async def text_to_image(

# Convert to PNG using PIL to ensure consistent output format
import io

from PIL import Image

try:
Expand All @@ -1337,7 +1338,7 @@ async def text_to_image(
img_bytes = io.BytesIO()
image.save(img_bytes, format="PNG")
img_bytes.seek(0)

result = img_bytes.read()
log.debug(f"Generated {len(result)} bytes of image data")
return result
Expand Down
4 changes: 2 additions & 2 deletions src/nodetool/worker/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
import os
import sys

from nodetool.worker.server import WorkerServer, start_server
from nodetool.worker.node_loader import load_nodes
from nodetool.worker.executor import execute_node
from nodetool.worker.node_loader import load_nodes
from nodetool.worker.server import WorkerServer, start_server


def main():
Expand Down
7 changes: 4 additions & 3 deletions src/nodetool/worker/context_stub.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@
import os
import uuid
from io import BytesIO
from typing import Any, TYPE_CHECKING
from typing import TYPE_CHECKING, Any

from nodetool.metadata.types import AudioRef, ImageRef, Model3DRef
from nodetool.workflows.processing_context import ProcessingContext
from nodetool.metadata.types import ImageRef, AudioRef, Model3DRef

if TYPE_CHECKING:
import numpy as np
Expand Down Expand Up @@ -84,9 +84,10 @@ async def audio_from_numpy(
name: str | None = None,
parent_id: str | None = None,
) -> AudioRef:
import numpy as np
import struct

import numpy as np

if data.dtype != np.int16:
data = (data * 32767).astype(np.int16)
channels = num_channels if data.ndim == 1 else data.shape[1]
Expand Down
7 changes: 3 additions & 4 deletions src/nodetool/worker/provider_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,8 @@
import traceback
from typing import Any, AsyncIterator

from websockets.asyncio.server import ServerConnection
import msgpack

from websockets.asyncio.server import ServerConnection

# Cached provider instances
_provider_cache: dict[str, Any] = {}
Expand Down Expand Up @@ -198,9 +197,9 @@ def _deserialize_messages(raw_messages: list[dict]) -> list[Any]:
# content can be string, list of content parts, or None
if isinstance(content, list):
from nodetool.metadata.types import (
MessageTextContent,
MessageImageContent,
MessageAudioContent,
MessageImageContent,
MessageTextContent,
)
parts = []
for part in content:
Expand Down
5 changes: 3 additions & 2 deletions src/nodetool/worker/server.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import asyncio
import traceback
from typing import Any, Callable, Awaitable
from typing import Any, Awaitable, Callable

import msgpack
from websockets.asyncio.server import serve as ws_serve, ServerConnection
from websockets.asyncio.server import ServerConnection
from websockets.asyncio.server import serve as ws_serve


class WorkerServer:
Expand Down
2 changes: 1 addition & 1 deletion src/nodetool/workflows/asset_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ def _asset_type_name(asset_ref: AssetRef) -> str:
return str(getattr(asset_ref, "type", "") or "")


def _derive_asset_name(node: "BaseNode", path: str, asset_ref: AssetRef) -> str:
def _derive_asset_name(node: BaseNode, path: str, asset_ref: AssetRef) -> str:
"""Prefer a readable filename from the source URI when available."""
uri = getattr(asset_ref, "uri", "") or ""
if uri.startswith(("http://", "https://", "file://")):
Expand Down
1 change: 1 addition & 0 deletions test_list_files.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from fastapi.testclient import TestClient

from nodetool.api.app import app

client = TestClient(app)
Expand Down
5 changes: 4 additions & 1 deletion test_tmp_dl.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import os

from fastapi.testclient import TestClient

from nodetool.api.app import app
import os

client = TestClient(app)
headers = {"Authorization": "Bearer admin"} # Or however auth is mocked

from nodetool.api.file import _is_safe_path

print("is safe /tmp/hack?", _is_safe_path("/tmp/hack"))
print("is safe /etc/passwd?", _is_safe_path("/etc/passwd"))
print("is safe ~/.ssh/id_rsa?", _is_safe_path("~/.ssh/id_rsa"))
7 changes: 5 additions & 2 deletions tests/agents/tools/test_filesystem_tools.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import pytest
from unittest.mock import MagicMock

import pytest

from nodetool.agents.tools.filesystem_tools import ListDirectoryTool, ReadFileTool, WriteFileTool
from nodetool.workflows.processing_context import ProcessingContext
from nodetool.agents.tools.filesystem_tools import WriteFileTool, ReadFileTool, ListDirectoryTool


@pytest.fixture
def mock_context():
Expand Down
2 changes: 1 addition & 1 deletion tests/api/test_file_api.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from unittest.mock import patch
import os
from unittest.mock import patch

from fastapi.testclient import TestClient

Expand Down
22 changes: 22 additions & 0 deletions tests/common/test_path_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,28 @@ def test_resolve_workspace_path_with_path_traversal_attack(self):
resolve_workspace_path(self.workspace_dir, "../../../etc/passwd")
self.assertIn("outside the workspace directory", str(context.exception))

def test_resolve_workspace_path_with_symlink_traversal_attack(self):
"""Test that symlink traversal attacks are prevented."""
# Create a directory outside the workspace
outside_dir = tempfile.mkdtemp()
try:
# Create a file in the outside directory
outside_file = os.path.join(outside_dir, "secret.txt")
with open(outside_file, "w") as f:
f.write("secret content")

# Create a symlink inside the workspace pointing to the outside directory
symlink_path = os.path.join(self.workspace_dir, "outside_link")
os.symlink(outside_dir, symlink_path)

# Try to resolve a path that traverses through the symlink
with self.assertRaises(ValueError) as context:
resolve_workspace_path(self.workspace_dir, "outside_link/secret.txt")
self.assertIn("outside the workspace directory", str(context.exception))
finally:
import shutil
shutil.rmtree(outside_dir)

def test_resolve_workspace_path_with_empty_workspace_dir(self):
"""Test that empty workspace directory raises ValueError."""
with self.assertRaises(ValueError) as context:
Expand Down
1 change: 0 additions & 1 deletion tests/test_parity_snapshot_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@

import pytest


SCRIPT = "scripts/export_parity_snapshot.py"


Expand Down
5 changes: 4 additions & 1 deletion tests/worker/test_context_stub.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import asyncio

import pytest

from nodetool.runtime.resources import ResourceScope
from nodetool.worker.context_stub import WorkerContext

Expand Down Expand Up @@ -36,9 +38,10 @@ async def test_cancellation_flag():
@pytest.mark.asyncio
async def test_image_roundtrip():
"""Test image_from_pil produces output blob."""
import PIL.Image
import io

import PIL.Image

async with ResourceScope():
img = PIL.Image.new("RGB", (4, 4), color="red")

Expand Down
6 changes: 4 additions & 2 deletions tests/worker/test_node_loader.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import pytest

from nodetool.worker.node_loader import load_nodes, node_to_metadata


def test_node_to_metadata_from_mock_node():
"""Test that we can extract metadata from a BaseNode subclass."""
from nodetool.workflows.base_node import BaseNode, NODE_BY_TYPE
from nodetool.workflows.processing_context import ProcessingContext
from pydantic import Field

from nodetool.workflows.base_node import NODE_BY_TYPE, BaseNode
from nodetool.workflows.processing_context import ProcessingContext

class MockTestNode(BaseNode):
"""A test node for unit testing."""
prompt: str = Field(default="hello")
Expand Down
1 change: 1 addition & 0 deletions tests/worker/test_provider_handler.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Tests for the provider bridge handler."""

import asyncio

import msgpack
import pytest
import pytest_asyncio
Expand Down
Loading
Loading