"""share_file tool — expose a local file to the user as a download link."""
import asyncio
import shutil
from pathlib import Path
from urllib.parse import quote
from navi.config import settings
from navi.session_files import ensure_session_dir
from ._internal.base import Tool, ToolContext, ToolResult, current_session_id, current_user_role, current_user_id
def _fmt_size(n: int) -> str:
if n < 1024:
return f"{n} B"
if n < 1024 ** 2:
return f"{n / 1024:.1f} KB"
return f"{n / 1024 ** 2:.1f} MB"
class ShareFileTool(Tool):
name = "share_file"
description = (
"Copy a local file into this session's directory and return a download link — for a "
"file the user should keep: an archive, report, export, dataset, PDF, source bundle.\n\n"
"`path` must be ABSOLUTE; a relative one is rejected. Resolve it first with "
"filesystem(action='info') or terminal('realpath ...').\n\n"
"For an inline viewer card rather than a download, use content_publish, which "
"publishes a file that is already in the session directory.\n\n"
'How to present the link: tool_manual("share_file").'
)
parameters = {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Absolute path to the file to share",
},
"filename": {
"type": "string",
"description": (
"Download filename shown to the user. "
"Defaults to the original filename. "
"Use this to give the file a clean, descriptive name."
),
},
},
"required": ["path"],
}
async def execute(self, params: dict, ctx: ToolContext | None = None) -> ToolResult:
session_id = ctx.session_id if ctx else current_session_id.get()
if not session_id:
return ToolResult(success=False, output="No active session context.", error="no_session")
raw_path = Path(params["path"]).expanduser()
user_id = ctx.user_id if ctx else current_user_id.get(None)
role = ctx.user_role if ctx else current_user_role.get()
if user_id and role != "admin":
sandbox = Path("user_data") / user_id
sandbox = sandbox.expanduser().resolve()
sandbox.mkdir(parents=True, exist_ok=True)
if raw_path.is_absolute():
src = raw_path.resolve()
try:
src.relative_to(sandbox)
except ValueError:
return ToolResult(
success=False,
output=f"Access denied: path is outside user sandbox.",
error="access_denied",
)
else:
src = (sandbox / raw_path).resolve()
else:
if not raw_path.is_absolute():
return ToolResult(
success=False,
output=(
f"share_file requires an absolute path, got: {params['path']}\n"
"Resolve it first with filesystem info or terminal realpath, then call share_file again."
),
error="path_not_absolute",
)
src = raw_path.resolve()
if not src.exists():
return ToolResult(success=False, output=f"File not found: {src}", error="not_found")
if not src.is_file():
return ToolResult(success=False, output=f"Path is not a file: {src}", error="not_a_file")
max_bytes = settings.share_file_max_size_mb * 1024 * 1024
src_size = src.stat().st_size
if src_size > max_bytes:
return ToolResult(
success=False,
output=(
f"File is too large to share: {_fmt_size(src_size)}. "
f"Limit: {settings.share_file_max_size_mb} MB."
),
error="file_too_large",
)
# Determine the download filename
download_name = Path(params.get("filename") or src.name).name or src.name
dest_dir = ensure_session_dir(session_id)
dest = dest_dir / download_name
# If it's already the same file in the session dir, skip copy
if dest.resolve() == src.resolve():
pass
else:
# Avoid clobbering an existing different file with the same name
if dest.exists():
stem = Path(download_name).stem
suffix = Path(download_name).suffix
i = 1
while dest.exists():
dest = dest_dir / f"{stem}_{i}{suffix}"
i += 1
await asyncio.to_thread(shutil.copy2, str(src), str(dest))
size = dest.stat().st_size
base_url = settings.public_url.rstrip("/")
url = f"{base_url}/api/sessions/{quote(session_id, safe='')}/files/{quote(dest.name, safe='')}"
return ToolResult(
success=True,
output=(
f"Download ready: {dest.name} ({_fmt_size(size)})\n"
f"URL: {url}\n\n"
f"→ Include this link in your reply: [{dest.name}]({url})"
),
metadata={"url": url, "filename": dest.name, "size": size},
)