feat: role-based tool access, confirmation gates, and new orchestrator tools
- auth_utils: get_user_role() reads role from auth.json (admin|user, default user) - manage_passwords: new `role` command to promote/demote users (admin-only by convention) - tools/__init__: TOOL_ROLES map, CONFIRM_REQUIRED set, get_tools_for_role(), get_openai_tools_for_role() — both orchestrators now filter tools by caller's role - tools/system: cortex_restart (detached subprocess, 5s delay), cortex_logs (admin-only) - tools/web: http_fetch — direct URL fetch, distinct from web_search - tools/files: file_list (directory listing), file_write (restricted paths, admin-only) - tools/notify: nc_talk_send — proactive outbound via notification.py - orchestrator_engine + openai_orchestrator: user_role param; CONFIRM_REQUIRED tools return a confirmation-request result instead of executing — loop breaks after Claude asks user to confirm in a follow-up message - home/scott/auth.json: role set to admin Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -45,6 +45,10 @@ from tools.scratch import (
|
||||
scratch_append as _scratch_append,
|
||||
scratch_clear as _scratch_clear,
|
||||
)
|
||||
from tools.system import cortex_restart as _cortex_restart, cortex_logs as _cortex_logs
|
||||
from tools.web import http_fetch as _http_fetch
|
||||
from tools.files import file_list as _file_list, file_write as _file_write
|
||||
from tools.notify import nc_talk_send as _nc_talk_send
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -285,8 +289,14 @@ _CALLABLES: dict[str, callable] = {
|
||||
"ae_journal_entry_prepend": _ae_journal_entry_prepend,
|
||||
"ae_task_list": _ae_task_list,
|
||||
"file_read": _file_read,
|
||||
"file_list": _file_list,
|
||||
"file_write": _file_write,
|
||||
"claude_allow_dir": _claude_allow_dir,
|
||||
"shell_exec": _shell_exec,
|
||||
"cortex_restart": _cortex_restart,
|
||||
"cortex_logs": _cortex_logs,
|
||||
"http_fetch": _http_fetch,
|
||||
"nc_talk_send": _nc_talk_send,
|
||||
"task_list": _task_list,
|
||||
"task_create": _task_create,
|
||||
"task_update": _task_update,
|
||||
@@ -640,46 +650,219 @@ _scratch_clear_declaration = types.FunctionDeclaration(
|
||||
parameters=types.Schema(type=types.Type.OBJECT, properties={}),
|
||||
)
|
||||
|
||||
_cortex_restart_declaration = types.FunctionDeclaration(
|
||||
name="cortex_restart",
|
||||
description=(
|
||||
"Restart the Cortex service via systemd. Schedules a restart 5 seconds from now. "
|
||||
"The current connection will drop — inform the user to refresh the page. "
|
||||
"Use after config changes, memory edits, or when the service needs a fresh start. "
|
||||
"ADMIN ONLY."
|
||||
),
|
||||
parameters=types.Schema(type=types.Type.OBJECT, properties={}),
|
||||
)
|
||||
|
||||
# Gemini Tool object — pass this to GenerateContentConfig
|
||||
TOOL_DECLARATIONS = [
|
||||
types.Tool(function_declarations=[
|
||||
_web_search_declaration,
|
||||
_ae_journal_list_declaration,
|
||||
_ae_journal_search_declaration,
|
||||
_ae_journal_entry_create_declaration,
|
||||
_ae_journal_entry_update_declaration,
|
||||
_ae_journal_entry_disable_declaration,
|
||||
_ae_journal_entry_append_declaration,
|
||||
_ae_journal_entry_prepend_declaration,
|
||||
_ae_task_list_declaration,
|
||||
_file_read_declaration,
|
||||
_claude_allow_dir_declaration,
|
||||
_shell_exec_declaration,
|
||||
_task_list_declaration,
|
||||
_task_create_declaration,
|
||||
_task_update_declaration,
|
||||
_task_complete_declaration,
|
||||
_cron_list_declaration,
|
||||
_cron_add_declaration,
|
||||
_cron_remove_declaration,
|
||||
_cron_toggle_declaration,
|
||||
_reminders_add_declaration,
|
||||
_reminders_list_declaration,
|
||||
_reminders_clear_declaration,
|
||||
_scratch_read_declaration,
|
||||
_scratch_write_declaration,
|
||||
_scratch_append_declaration,
|
||||
_scratch_clear_declaration,
|
||||
])
|
||||
_cortex_logs_declaration = types.FunctionDeclaration(
|
||||
name="cortex_logs",
|
||||
description=(
|
||||
"Fetch recent lines from the Cortex systemd service journal. "
|
||||
"Use for debugging errors, checking startup status, or reviewing recent activity. "
|
||||
"ADMIN ONLY."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
properties={
|
||||
"lines": types.Schema(
|
||||
type=types.Type.INTEGER,
|
||||
description="Number of log lines to return (default 50, max 200)",
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
_http_fetch_declaration = types.FunctionDeclaration(
|
||||
name="http_fetch",
|
||||
description=(
|
||||
"Fetch a specific URL and return the response. Unlike web_search, this hits "
|
||||
"a direct URL — useful for health checks, JSON API endpoints, webhook testing, "
|
||||
"or reading a specific page when you already know the URL. "
|
||||
"Response body is capped at 8 KB."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
properties={
|
||||
"url": types.Schema(type=types.Type.STRING, description="Full URL to fetch"),
|
||||
"method": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description="HTTP method: GET (default), POST, HEAD",
|
||||
),
|
||||
"body": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description="Optional request body (for POST requests)",
|
||||
),
|
||||
"timeout": types.Schema(
|
||||
type=types.Type.INTEGER,
|
||||
description="Request timeout in seconds (default 15, max 60)",
|
||||
),
|
||||
},
|
||||
required=["url"],
|
||||
),
|
||||
)
|
||||
|
||||
_file_list_declaration = types.FunctionDeclaration(
|
||||
name="file_list",
|
||||
description=(
|
||||
"List the files and subdirectories in a directory. "
|
||||
"Allowed paths: ~/agents_sync/, ~/OSIT_dev/, ~/DgrZone_Nextcloud/, ~/OSIT_Nextcloud/. "
|
||||
"ADMIN ONLY."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
properties={
|
||||
"path": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description="Absolute or home-relative path to the directory",
|
||||
),
|
||||
},
|
||||
required=["path"],
|
||||
),
|
||||
)
|
||||
|
||||
_file_write_declaration = types.FunctionDeclaration(
|
||||
name="file_write",
|
||||
description=(
|
||||
"Write or append content to a file. "
|
||||
"Write-allowed paths: ~/agents_sync/ and the Cortex home/ directory. "
|
||||
"Creates parent directories if needed. "
|
||||
"ADMIN ONLY. Requires user confirmation before executing."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
properties={
|
||||
"path": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description="Absolute or home-relative path to write to",
|
||||
),
|
||||
"content": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description="Content to write",
|
||||
),
|
||||
"mode": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description="'overwrite' (default, replaces file) or 'append' (adds to end)",
|
||||
),
|
||||
},
|
||||
required=["path", "content"],
|
||||
),
|
||||
)
|
||||
|
||||
_nc_talk_send_declaration = types.FunctionDeclaration(
|
||||
name="nc_talk_send",
|
||||
description=(
|
||||
"Send a proactive message to the user via their configured notification channel "
|
||||
"(Nextcloud Talk by default). Use this to notify the user of completed background "
|
||||
"tasks, important events, or anything they should know between sessions. "
|
||||
"Requires notification_channel and notification_room set in channels.json."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
properties={
|
||||
"message": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description="The message to send to the user",
|
||||
),
|
||||
},
|
||||
required=["message"],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Role-based access control
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Minimum role required to use each tool. Unlisted tools default to "user".
|
||||
TOOL_ROLES: dict[str, str] = {
|
||||
# Admin-only — system-level or broad filesystem access
|
||||
"shell_exec": "admin",
|
||||
"claude_allow_dir": "admin",
|
||||
"cortex_restart": "admin",
|
||||
"cortex_logs": "admin",
|
||||
"file_read": "admin",
|
||||
"file_list": "admin",
|
||||
"file_write": "admin",
|
||||
"ae_task_list": "admin", # reads agents_sync kanban
|
||||
}
|
||||
|
||||
# Tools that require explicit user confirmation before executing.
|
||||
# The orchestrator injects a CONFIRMATION_REQUIRED result instead of calling
|
||||
# the tool, prompting Claude to ask the user to confirm in a follow-up message.
|
||||
CONFIRM_REQUIRED: set[str] = {
|
||||
"cortex_restart",
|
||||
"file_write",
|
||||
"shell_exec",
|
||||
"cron_remove",
|
||||
"reminders_clear",
|
||||
}
|
||||
|
||||
_ROLE_RANK: dict[str, int] = {"user": 0, "admin": 1}
|
||||
|
||||
|
||||
def _role_allowed(tool_name: str, role: str) -> bool:
|
||||
required = TOOL_ROLES.get(tool_name, "user")
|
||||
return _ROLE_RANK.get(role, 0) >= _ROLE_RANK.get(required, 0)
|
||||
|
||||
|
||||
# Flat list of all declarations — single source of truth for both Gemini and OpenAI formats.
|
||||
_ALL_DECLARATIONS: list[types.FunctionDeclaration] = [
|
||||
_web_search_declaration,
|
||||
_ae_journal_list_declaration,
|
||||
_ae_journal_search_declaration,
|
||||
_ae_journal_entry_create_declaration,
|
||||
_ae_journal_entry_update_declaration,
|
||||
_ae_journal_entry_disable_declaration,
|
||||
_ae_journal_entry_append_declaration,
|
||||
_ae_journal_entry_prepend_declaration,
|
||||
_ae_task_list_declaration,
|
||||
_file_read_declaration,
|
||||
_file_list_declaration,
|
||||
_file_write_declaration,
|
||||
_claude_allow_dir_declaration,
|
||||
_shell_exec_declaration,
|
||||
_cortex_restart_declaration,
|
||||
_cortex_logs_declaration,
|
||||
_http_fetch_declaration,
|
||||
_nc_talk_send_declaration,
|
||||
_task_list_declaration,
|
||||
_task_create_declaration,
|
||||
_task_update_declaration,
|
||||
_task_complete_declaration,
|
||||
_cron_list_declaration,
|
||||
_cron_add_declaration,
|
||||
_cron_remove_declaration,
|
||||
_cron_toggle_declaration,
|
||||
_reminders_add_declaration,
|
||||
_reminders_list_declaration,
|
||||
_reminders_clear_declaration,
|
||||
_scratch_read_declaration,
|
||||
_scratch_write_declaration,
|
||||
_scratch_append_declaration,
|
||||
_scratch_clear_declaration,
|
||||
]
|
||||
|
||||
# Full Gemini Tool object (all tools — use get_tools_for_role() in production)
|
||||
TOOL_DECLARATIONS = [types.Tool(function_declarations=_ALL_DECLARATIONS)]
|
||||
|
||||
async def call_tool(name: str, args: dict) -> str:
|
||||
"""Dispatch a tool call by name. Returns result as a string."""
|
||||
fn = _CALLABLES.get(name)
|
||||
|
||||
async def call_tool(name: str, args: dict, callables: dict | None = None) -> str:
|
||||
"""Dispatch a tool call by name. Returns result as a string.
|
||||
|
||||
Pass `callables` (from get_tools_for_role) to enforce role restrictions.
|
||||
Falls back to the full _CALLABLES dict if omitted.
|
||||
"""
|
||||
dispatch = callables if callables is not None else _CALLABLES
|
||||
fn = dispatch.get(name)
|
||||
if fn is None:
|
||||
return f"Unknown tool: {name}"
|
||||
return f"Tool not available or access denied: {name}"
|
||||
return await fn(**args)
|
||||
|
||||
|
||||
@@ -718,9 +901,9 @@ def _schema_to_json(schema) -> dict:
|
||||
|
||||
|
||||
def _build_openai_tools() -> list[dict]:
|
||||
"""Convert TOOL_DECLARATIONS (Gemini format) to OpenAI tool schemas."""
|
||||
"""Convert _ALL_DECLARATIONS (Gemini format) to OpenAI tool schemas."""
|
||||
out = []
|
||||
for decl in TOOL_DECLARATIONS[0].function_declarations:
|
||||
for decl in _ALL_DECLARATIONS:
|
||||
params = (
|
||||
_schema_to_json(decl.parameters)
|
||||
if decl.parameters
|
||||
@@ -737,5 +920,27 @@ def _build_openai_tools() -> list[dict]:
|
||||
return out
|
||||
|
||||
|
||||
# OpenAI-format tool list — pass to client.chat.completions.create(tools=...)
|
||||
# OpenAI-format tool list — all tools (use get_openai_tools_for_role() in production)
|
||||
OPENAI_TOOL_SCHEMAS: list[dict] = _build_openai_tools()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Role-filtered tool access
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_tools_for_role(role: str) -> tuple[list, dict]:
|
||||
"""Return (gemini_tool_declarations, callables_dict) filtered to tools the role can use.
|
||||
|
||||
Usage in orchestrator:
|
||||
tool_declarations, tool_callables = get_tools_for_role(user_role)
|
||||
"""
|
||||
allowed = {name for name in _CALLABLES if _role_allowed(name, role)}
|
||||
decls = [d for d in _ALL_DECLARATIONS if d.name in allowed]
|
||||
callables = {k: v for k, v in _CALLABLES.items() if k in allowed}
|
||||
return [types.Tool(function_declarations=decls)], callables
|
||||
|
||||
|
||||
def get_openai_tools_for_role(role: str) -> list[dict]:
|
||||
"""Return OpenAI tool schemas filtered to tools the role can use."""
|
||||
allowed = {name for name in _CALLABLES if _role_allowed(name, role)}
|
||||
return [t for t in OPENAI_TOOL_SCHEMAS if t["function"]["name"] in allowed]
|
||||
|
||||
@@ -110,3 +110,105 @@ def _is_allowed(resolved: Path) -> bool:
|
||||
except ValueError:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
# Write is restricted to a tighter set of paths to limit blast radius.
|
||||
_WRITE_ROOTS: list[Path] = [
|
||||
Path.home() / "agents_sync",
|
||||
]
|
||||
|
||||
|
||||
def _is_write_allowed(resolved: Path) -> bool:
|
||||
for root in _WRITE_ROOTS:
|
||||
try:
|
||||
resolved.relative_to(root)
|
||||
return True
|
||||
except ValueError:
|
||||
continue
|
||||
# Also allow the Cortex home/ directory (persona memory, tasks, etc.)
|
||||
try:
|
||||
from config import settings
|
||||
cortex_home = settings.home_root()
|
||||
resolved.relative_to(cortex_home)
|
||||
return True
|
||||
except (ValueError, Exception):
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
async def file_list(path: str) -> str:
|
||||
"""List the contents of a directory.
|
||||
|
||||
Returns names of files and subdirectories with type indicators (/ for dirs).
|
||||
Same allow-list as file_read.
|
||||
"""
|
||||
return await asyncio.to_thread(_sync_file_list, path)
|
||||
|
||||
|
||||
def _sync_file_list(path: str) -> str:
|
||||
try:
|
||||
resolved = Path(path).expanduser().resolve()
|
||||
except Exception as e:
|
||||
return f"Invalid path: {e}"
|
||||
|
||||
if not _is_allowed(resolved):
|
||||
allowed_str = ", ".join(str(r) for r in _ALLOWED_ROOTS)
|
||||
return f"Access denied: {resolved}\nAllowed directories: {allowed_str}"
|
||||
|
||||
if not resolved.exists():
|
||||
return f"Path not found: {resolved}"
|
||||
|
||||
if resolved.is_file():
|
||||
return f"{resolved} is a file, not a directory. Use file_read to read it."
|
||||
|
||||
try:
|
||||
entries = sorted(resolved.iterdir(), key=lambda e: (e.is_file(), e.name.lower()))
|
||||
lines = []
|
||||
for e in entries[:200]:
|
||||
suffix = "/" if e.is_dir() else f" ({e.stat().st_size} bytes)" if e.is_file() else ""
|
||||
lines.append(f"{e.name}{suffix}")
|
||||
result = "\n".join(lines)
|
||||
if len(entries) > 200:
|
||||
result += f"\n… ({len(entries) - 200} more entries not shown)"
|
||||
return f"Contents of {resolved}:\n\n{result}"
|
||||
except Exception as e:
|
||||
return f"Cannot list directory: {e}"
|
||||
|
||||
|
||||
async def file_write(path: str, content: str, mode: str = "overwrite") -> str:
|
||||
"""Write content to a file.
|
||||
|
||||
mode: 'overwrite' (default) replaces the file; 'append' adds to the end.
|
||||
Write-allowed paths: ~/agents_sync/ and the Cortex home/ directory.
|
||||
Parent directories are created if they don't exist.
|
||||
"""
|
||||
return await asyncio.to_thread(_sync_file_write, path, content, mode)
|
||||
|
||||
|
||||
def _sync_file_write(path: str, content: str, mode: str) -> str:
|
||||
try:
|
||||
resolved = Path(path).expanduser().resolve()
|
||||
except Exception as e:
|
||||
return f"Invalid path: {e}"
|
||||
|
||||
if not _is_write_allowed(resolved):
|
||||
return (
|
||||
f"Write access denied: {resolved}\n"
|
||||
f"Allowed write roots: ~/agents_sync/ and the Cortex home/ directory."
|
||||
)
|
||||
|
||||
if mode not in ("overwrite", "append"):
|
||||
return f"Invalid mode '{mode}' — use 'overwrite' or 'append'."
|
||||
|
||||
try:
|
||||
resolved.parent.mkdir(parents=True, exist_ok=True)
|
||||
if mode == "append":
|
||||
with resolved.open("a", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
return f"Appended {len(content)} chars to {resolved}"
|
||||
else:
|
||||
resolved.write_text(content, encoding="utf-8")
|
||||
return f"Wrote {len(content)} chars to {resolved}"
|
||||
except Exception as e:
|
||||
logger.error("file_write error for %s: %s", resolved, e)
|
||||
return f"Write error: {e}"
|
||||
|
||||
28
cortex/tools/notify.py
Normal file
28
cortex/tools/notify.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
Notification tools — proactively send messages to user channels.
|
||||
|
||||
nc_talk_send routes through notification.py → channels.json.
|
||||
Requires notification_channel and notification_room set in the user's channels.json.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from persona import get_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def nc_talk_send(message: str) -> str:
|
||||
"""Send a message to the user via their configured notification channel.
|
||||
|
||||
Channel is resolved from the user's channels.json (notification_channel key).
|
||||
Falls back to Nextcloud Talk if configured. No-op if no channel is set.
|
||||
"""
|
||||
from notification import notify
|
||||
username = get_user()
|
||||
try:
|
||||
await notify(username, message)
|
||||
return f"Message sent to {username}'s notification channel."
|
||||
except Exception as e:
|
||||
logger.warning("nc_talk_send error for %s: %s", username, e)
|
||||
return f"Failed to send notification: {e}"
|
||||
@@ -2,11 +2,13 @@
|
||||
System tools — local machine operations.
|
||||
|
||||
These tools affect the host system directly. Use with care.
|
||||
cortex_restart and cortex_logs require admin role.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -83,3 +85,42 @@ async def shell_exec(command: str, working_dir: str | None = None, timeout: int
|
||||
except Exception as e:
|
||||
logger.error("shell_exec error: %s", e)
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
async def cortex_restart() -> str:
|
||||
"""Schedule a Cortex service restart 5 seconds from now.
|
||||
|
||||
Uses a detached subprocess so the restart survives the current process being
|
||||
terminated by systemd. The calling session will drop — user should refresh.
|
||||
"""
|
||||
subprocess.Popen(
|
||||
["bash", "-c", "sleep 5 && systemctl --user restart cortex"],
|
||||
start_new_session=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
close_fds=True,
|
||||
)
|
||||
logger.info("cortex_restart: restart scheduled in 5 seconds")
|
||||
return (
|
||||
"Cortex restart scheduled in 5 seconds. "
|
||||
"The current connection will drop — please refresh the page after a moment."
|
||||
)
|
||||
|
||||
|
||||
async def cortex_logs(lines: int = 50) -> str:
|
||||
"""Return recent lines from the Cortex systemd journal."""
|
||||
n = min(max(int(lines), 1), 200)
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"journalctl", "--user", "-u", "cortex", f"-n{n}", "--no-pager",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=15)
|
||||
out = stdout.decode(errors="replace").strip()
|
||||
return out or stderr.decode(errors="replace").strip() or "No log output."
|
||||
except asyncio.TimeoutError:
|
||||
return "Error: journalctl timed out"
|
||||
except Exception as e:
|
||||
logger.error("cortex_logs error: %s", e)
|
||||
return f"Error: {e}"
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"""
|
||||
Web search tool — DuckDuckGo backend.
|
||||
|
||||
Uses the duckduckgo-search library. Set DDG_API_KEY in .env for a paid account
|
||||
(higher rate limits). The free unauthenticated tier works for moderate usage.
|
||||
Web tools — search (DuckDuckGo) and direct HTTP fetch.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -48,3 +48,29 @@ def _sync_search(query: str, max_results: int) -> list[dict]:
|
||||
except Exception as e:
|
||||
logger.warning("DuckDuckGo search error: %s", e)
|
||||
return []
|
||||
|
||||
|
||||
async def http_fetch(
|
||||
url: str,
|
||||
method: str = "GET",
|
||||
body: str | None = None,
|
||||
timeout: int = 15,
|
||||
) -> str:
|
||||
"""Fetch a URL directly and return the response body.
|
||||
|
||||
Unlike web_search, this hits a specific URL — useful for health checks,
|
||||
API probing, JSON endpoints, webhook testing, etc.
|
||||
Response body is capped at 8 KB.
|
||||
"""
|
||||
method = method.upper()
|
||||
timeout = min(max(int(timeout), 1), 60)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
||||
resp = await client.request(method, url, content=body)
|
||||
body_text = resp.text[:8192]
|
||||
return f"HTTP {resp.status_code} {resp.url}\n\n{body_text}"
|
||||
except httpx.HTTPError as e:
|
||||
return f"HTTP error: {e}"
|
||||
except Exception as e:
|
||||
logger.warning("http_fetch error for %s: %s", url, e)
|
||||
return f"Error: {e}"
|
||||
|
||||
Reference in New Issue
Block a user