Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .flocks/flocks.json.example
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@
"max_age_days": 3
}
},
"server": {
"cors": ["http://127.0.0.1:5173", "http://localhost:5173"]
},
"allowReadPaths": [],
"updater": {
"enabled": true,
"repo": "AgentFlocks/flocks",
Expand Down
5 changes: 5 additions & 0 deletions flocks/cli/service_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -704,11 +704,16 @@ def start_backend(config: ServiceConfig, console) -> None:
str(config.backend_port),
]

backend_env = os.environ.copy()
backend_env["_FLOCKS_WEBUI_HOST"] = config.frontend_host
backend_env["_FLOCKS_WEBUI_PORT"] = str(config.frontend_port)

console.print("[flocks] 启动后端服务...")
process = _spawn_process(
command,
cwd=root,
log_path=paths.backend_log,
env=backend_env,
)
write_runtime_record(
paths.backend_pid,
Expand Down
11 changes: 10 additions & 1 deletion flocks/config/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -533,7 +533,16 @@ class ConfigInfo(BaseModel):
"workspace_access (none/ro/rw), workspace_root, docker, tools, prune."
),
)

allow_read_paths: Optional[List[str]] = Field(
None,
alias="allowReadPaths",
description=(
"Extra absolute paths (directories or prefixes) allowed for HTTP "
"/api/file/content and /api/file/list. Does not replace project root, data, or "
"workspace; the Flocks config directory and ~/.ssh are never allowed."
),
)

# Channel configuration (IM platform integrations)
channels: Optional[Dict[str, ChannelConfig]] = Field(
None,
Expand Down
108 changes: 97 additions & 11 deletions flocks/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -297,15 +297,100 @@ async def lifespan(app: FastAPI):


# CORS Configuration
def configure_cors() -> None:
"""Configure CORS middleware"""
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # TODO: Make configurable
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
#
# Priority order:
# 1. Explicit ``server.cors`` in flocks.json → use those origins (plus
# the localhost fallback regex).
# 2. ``_FLOCKS_WEBUI_HOST`` / ``_FLOCKS_WEBUI_PORT`` env vars set by
# ``start_backend()`` for a concrete IP → auto-whitelist that single
# origin. We deliberately do NOT auto-whitelist when the WebUI binds
# to ``0.0.0.0``: matching ``[^/]+:<port>`` would accept every host on
# that port, effectively disabling CORS. Remote deployments that run
# ``--webui-host 0.0.0.0`` must set ``server.cors`` explicitly in
# ``flocks.json``.
# 3. Fallback → only localhost (any port) via regex.
#
# Config is read lazily on the first request via
# :class:`_DeferredCORSMiddleware` so that importing ``app`` in an async
# context (e.g. pytest fixtures) does not call ``asyncio.run()`` inside a
# running event loop, and so that ``Config.get_global()`` is not invoked at
# import time — which would otherwise cache ``HOME`` before test harnesses
# can monkey-patch it.

_LOCALHOST_ORIGIN_RE = r"^https?://(127\.0\.0\.1|localhost)(:\d+)?$"

_LOCALHOST_HOSTS = {"127.0.0.1", "localhost", "::1"}


def _is_localhost(host: str) -> bool:
return host in _LOCALHOST_HOSTS


def _read_cors_config() -> tuple[list[str], Optional[str]]:
"""Return (allow_origins, allow_origin_regex) for CORSMiddleware.

Reads ``server.cors`` directly from ``flocks.json`` using synchronous
JSON I/O — this avoids ``asyncio.run()`` inside a running event loop
and keeps the hot path off the async ``Config.get()`` pipeline.
"""
import json

try:
cfg_file = Config.get_config_file()
if cfg_file.exists():
with cfg_file.open("r", encoding="utf-8") as f:
data = json.load(f)
server_cfg = data.get("server") or {}
cors = server_cfg.get("cors")
if isinstance(cors, list):
origins = [c for c in cors if isinstance(c, str) and c]
if origins:
return origins, _LOCALHOST_ORIGIN_RE
except Exception:
pass

webui_host = os.environ.get("_FLOCKS_WEBUI_HOST", "")
webui_port = os.environ.get("_FLOCKS_WEBUI_PORT", "")

if (
webui_host
and webui_port
and not _is_localhost(webui_host)
and webui_host != "0.0.0.0"
):
extra_origin = f"http://{webui_host}:{webui_port}"
return [extra_origin], _LOCALHOST_ORIGIN_RE

return [], _LOCALHOST_ORIGIN_RE


class _DeferredCORSMiddleware:
"""Lazy wrapper around :class:`CORSMiddleware`.

Starlette builds the middleware stack on the first request, but the
inner middleware's constructor kwargs are evaluated at
``add_middleware`` call time. We defer one step further: the wrapped
:class:`CORSMiddleware` is instantiated on the first incoming request,
after the test harness (or the real runtime) has finished setting up
``HOME`` / config paths.
"""

def __init__(self, app) -> None:
self.app = app
self._inner = None

async def __call__(self, scope, receive, send):
if self._inner is None:
allow_origins, allow_origin_regex = _read_cors_config()
self._inner = CORSMiddleware(
self.app,
allow_origins=allow_origins,
allow_origin_regex=allow_origin_regex,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
await self._inner(scope, receive, send)


# Instance Context Middleware
Expand Down Expand Up @@ -453,8 +538,9 @@ async def general_exception_handler(request: Request, exc: Exception):
)


# Configure CORS
configure_cors()
# Configure CORS (config is read lazily on the first request; see
# _DeferredCORSMiddleware for rationale).
app.add_middleware(_DeferredCORSMiddleware)


# Import and include routers
Expand Down
49 changes: 42 additions & 7 deletions flocks/server/routes/file.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel

from flocks.config.config import Config
from flocks.utils.file import File, FileNode, FileContent, FileInfo
from flocks.utils.http_file_read_guard import resolve_path_for_http_file_access
from flocks.utils.log import Log

router = APIRouter()
Expand All @@ -23,8 +25,13 @@ async def list_files(path: str = Query(..., description="Directory path")):
List files and directories in a specified path.
"""
try:
nodes = await File.list(path)
cfg = await Config.get()
safe_path = await resolve_path_for_http_file_access(path, cfg)
nodes = await File.list(safe_path)
return nodes
except PermissionError:
log.warning("http_file.list.denied", {"path": path})
raise HTTPException(status_code=403, detail="Access denied")
except Exception as e:
log.error("file.list.error", {"error": str(e), "path": path})
raise HTTPException(status_code=500, detail=str(e))
Expand All @@ -38,10 +45,15 @@ async def read_file(path: str = Query(..., description="File path")):
Read the content of a specified file.
"""
try:
content = await File.read(path)
cfg = await Config.get()
safe_path = await resolve_path_for_http_file_access(path, cfg)
content = await File.read(safe_path)
return content
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except PermissionError:
log.warning("http_file.read.denied", {"path": path})
raise HTTPException(status_code=403, detail="Access denied")
except Exception as e:
log.error("file.read.error", {"error": str(e), "path": path})
raise HTTPException(status_code=500, detail=str(e))
Expand All @@ -59,6 +71,14 @@ async def search_files(

Search for files or directories by name or pattern in the project directory.
"""
# Only enforce basic resource limits here. The underlying ``File.search``
# invokes ``subprocess.run`` with ``shell=False`` and an argv list, so the
# query cannot reach a shell — there is no injection vector that warrants
# rejecting legitimate filenames containing ``;`` ``|`` ``$`` ``` ``` etc.
# The null byte is still refused because many POSIX APIs treat it as a
# string terminator and will silently truncate the argument.
if not query or len(query) > 200 or "\x00" in query:
raise HTTPException(status_code=400, detail="Invalid search query")
try:
results = await File.search(query=query, limit=limit, dirs=dirs, type=type)
return results
Expand Down Expand Up @@ -103,14 +123,26 @@ async def find_text(pattern: str = Query(..., description="Search pattern")):
Find text

Search for text patterns across files in the project using grep.
Only searches within the Flocks project root directory.
"""
try:
import subprocess
import os

cwd = os.getcwd()

# Use grep for text search
from flocks.utils.paths import find_flocks_project_root

project_root = find_flocks_project_root()
if project_root is None:
raise HTTPException(status_code=403, detail="No Flocks project root found")
cwd = str(project_root)

if not pattern or len(pattern) > 500 or "\x00" in pattern:
raise HTTPException(status_code=400, detail="Invalid search pattern")

# ``grep`` runs as its own argv (no shell); the ``--`` sentinel stops
# ``grep`` from interpreting a leading ``-`` in ``pattern`` as an
# option. We intentionally keep regex semantics (the historical
# contract) and rely on ``subprocess.run(shell=False)`` plus the
# bounded cwd (``project_root``) to contain the command.
cmd = [
"grep",
"-rn", # recursive, line numbers
Expand All @@ -132,6 +164,7 @@ async def find_text(pattern: str = Query(..., description="Search pattern")):
"--exclude-dir=__pycache__",
"--exclude-dir=.venv",
"--exclude-dir=venv",
"--",
pattern,
".",
]
Expand All @@ -147,7 +180,7 @@ async def find_text(pattern: str = Query(..., description="Search pattern")):
matches = []

if result.returncode == 0:
for line in result.stdout.split("\n")[:100]: # Limit to 100 matches
for line in result.stdout.split("\n")[:100]:
if not line.strip():
continue

Expand All @@ -164,6 +197,8 @@ async def find_text(pattern: str = Query(..., description="Search pattern")):
})

return matches
except HTTPException:
raise
except Exception as e:
log.error("text.search.error", {"error": str(e), "pattern": pattern})
raise HTTPException(status_code=500, detail=str(e))
11 changes: 9 additions & 2 deletions flocks/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@
from flocks.utils.log import Log
from flocks.utils.id import Identifier
from flocks.utils.json_repair import parse_json_robust, repair_truncated_json
from flocks.utils.paths import find_project_root
from flocks.utils.paths import find_flocks_project_root, find_project_root

__all__ = ["Log", "Identifier", "parse_json_robust", "repair_truncated_json", "find_project_root"]
__all__ = [
"Log",
"Identifier",
"parse_json_robust",
"repair_truncated_json",
"find_project_root",
"find_flocks_project_root",
]
Loading
Loading