Phase 14: multi-host file browser (0.15.0)
The Files page gained a host switcher: when agents are registered, a Host
dropdown switches the whole browser between the local host and any online agent
(switching resets path + clipboard). Every file operation is sandboxed by the
selected agent's own ALLOWED_BROWSE_ROOTS/HOST_ROOT_PREFIX.
- agent_app.py: /agent/files/* (list/read/download/write/mkdir/touch/rename/
copy/move/delete/upload) reusing file_service + device_service; BrowseError
-> HTTP 400.
- routers/agents.py: proxy routes at /api/agents/{id}/files/* (audit-logged
mutations); download streams via download_to_file, upload via upload_file.
Reuses the WriteBody/NameBody/RenameBody/TransferBody models from routers.files.
- Frontend: filesApi methods take an optional trailing agentId; Files.tsx tracks
a host and threads it through every call, query key, and the editor/dialogs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
012614f5fb
commit
c5f591749f
@@ -17,6 +17,7 @@ from models.agent import Agent, AgentCreate, AgentRead, AgentUpdate
|
||||
from models.backup_destination import BackupDestination
|
||||
from models.stack import StackCreate, StackUpdate
|
||||
from models.user import User
|
||||
from routers.files import NameBody, RenameBody, TransferBody, WriteBody
|
||||
from routers.networks import ContainerRef, NetworkCreate
|
||||
from services import (
|
||||
agent_service,
|
||||
@@ -642,3 +643,213 @@ async def agent_image_check(
|
||||
ip=_ip(request),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# File browser (proxied)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@router.get("/{agent_id}/files/list")
|
||||
async def agent_files_list(
|
||||
agent_id: int,
|
||||
path: str = Query("/"),
|
||||
show_hidden: bool = Query(False),
|
||||
session: Session = Depends(get_session),
|
||||
_user: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
agent = _get_or_404(session, agent_id)
|
||||
return await _proxy(
|
||||
session, agent, "GET", "/agent/files/list",
|
||||
params={"path": path, "show_hidden": show_hidden},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{agent_id}/files/read")
|
||||
async def agent_files_read(
|
||||
agent_id: int,
|
||||
path: str = Query(...),
|
||||
session: Session = Depends(get_session),
|
||||
_user: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
agent = _get_or_404(session, agent_id)
|
||||
return await _proxy(session, agent, "GET", "/agent/files/read", params={"path": path})
|
||||
|
||||
|
||||
@router.get("/{agent_id}/files/download")
|
||||
async def agent_files_download(
|
||||
agent_id: int,
|
||||
path: str = Query(...),
|
||||
session: Session = Depends(get_session),
|
||||
_user: User = Depends(get_current_user),
|
||||
):
|
||||
agent = _get_or_404(session, agent_id)
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False)
|
||||
tmp.close()
|
||||
try:
|
||||
await agent_service.download_to_file(
|
||||
session, agent, "/agent/files/download", tmp.name, params={"path": path}
|
||||
)
|
||||
except AgentError as exc:
|
||||
if os.path.exists(tmp.name):
|
||||
os.unlink(tmp.name)
|
||||
_raise(exc)
|
||||
return FileResponse(
|
||||
tmp.name, media_type="application/octet-stream", filename=os.path.basename(path),
|
||||
background=BackgroundTask(os.unlink, tmp.name),
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{agent_id}/files/write")
|
||||
async def agent_files_write(
|
||||
agent_id: int,
|
||||
body: WriteBody,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(require_admin),
|
||||
) -> dict:
|
||||
agent = _get_or_404(session, agent_id)
|
||||
result = await _proxy(session, agent, "PUT", "/agent/files/write", json=body.model_dump())
|
||||
audit_service.record(
|
||||
session, user=user.username, action="agent.file.write",
|
||||
target=f"{agent.name}:{body.path}", ip=_ip(request),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/{agent_id}/files/mkdir")
|
||||
async def agent_files_mkdir(
|
||||
agent_id: int,
|
||||
body: NameBody,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(require_admin),
|
||||
) -> dict:
|
||||
agent = _get_or_404(session, agent_id)
|
||||
result = await _proxy(session, agent, "POST", "/agent/files/mkdir", json=body.model_dump())
|
||||
audit_service.record(
|
||||
session, user=user.username, action="agent.file.mkdir",
|
||||
target=f"{agent.name}:{result.get('path')}", ip=_ip(request),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/{agent_id}/files/touch")
|
||||
async def agent_files_touch(
|
||||
agent_id: int,
|
||||
body: NameBody,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(require_admin),
|
||||
) -> dict:
|
||||
agent = _get_or_404(session, agent_id)
|
||||
result = await _proxy(session, agent, "POST", "/agent/files/touch", json=body.model_dump())
|
||||
audit_service.record(
|
||||
session, user=user.username, action="agent.file.create",
|
||||
target=f"{agent.name}:{result.get('path')}", ip=_ip(request),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/{agent_id}/files/rename")
|
||||
async def agent_files_rename(
|
||||
agent_id: int,
|
||||
body: RenameBody,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(require_admin),
|
||||
) -> dict:
|
||||
agent = _get_or_404(session, agent_id)
|
||||
result = await _proxy(session, agent, "POST", "/agent/files/rename", json=body.model_dump())
|
||||
audit_service.record(
|
||||
session, user=user.username, action="agent.file.rename",
|
||||
target=f"{agent.name}:{body.path}", detail=f"-> {result.get('path')}", ip=_ip(request),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/{agent_id}/files/copy")
|
||||
async def agent_files_copy(
|
||||
agent_id: int,
|
||||
body: TransferBody,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(require_admin),
|
||||
) -> dict:
|
||||
agent = _get_or_404(session, agent_id)
|
||||
result = await _proxy(session, agent, "POST", "/agent/files/copy", json=body.model_dump())
|
||||
audit_service.record(
|
||||
session, user=user.username, action="agent.file.copy",
|
||||
target=f"{agent.name}:{body.src}", detail=f"-> {result.get('path')}", ip=_ip(request),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/{agent_id}/files/move")
|
||||
async def agent_files_move(
|
||||
agent_id: int,
|
||||
body: TransferBody,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(require_admin),
|
||||
) -> dict:
|
||||
agent = _get_or_404(session, agent_id)
|
||||
result = await _proxy(session, agent, "POST", "/agent/files/move", json=body.model_dump())
|
||||
audit_service.record(
|
||||
session, user=user.username, action="agent.file.move",
|
||||
target=f"{agent.name}:{body.src}", detail=f"-> {result.get('path')}", ip=_ip(request),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/{agent_id}/files")
|
||||
async def agent_files_delete(
|
||||
agent_id: int,
|
||||
request: Request,
|
||||
path: str = Query(...),
|
||||
recursive: bool = Query(False),
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(require_admin),
|
||||
) -> dict:
|
||||
agent = _get_or_404(session, agent_id)
|
||||
result = await _proxy(
|
||||
session, agent, "DELETE", "/agent/files", params={"path": path, "recursive": recursive}
|
||||
)
|
||||
audit_service.record(
|
||||
session, user=user.username, action="agent.file.delete",
|
||||
target=f"{agent.name}:{path}", detail="recursive" if recursive else None, ip=_ip(request),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/{agent_id}/files/upload")
|
||||
async def agent_files_upload(
|
||||
agent_id: int,
|
||||
request: Request,
|
||||
path: str = Form(...),
|
||||
overwrite: bool = Form(False),
|
||||
rel_path: str = Form(""),
|
||||
file: UploadFile = File(...),
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(require_admin),
|
||||
) -> dict:
|
||||
agent = _get_or_404(session, agent_id)
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False)
|
||||
try:
|
||||
while chunk := await file.read(1024 * 1024):
|
||||
tmp.write(chunk)
|
||||
tmp.close()
|
||||
result = await agent_service.upload_file(
|
||||
session, agent, "/agent/files/upload", tmp.name, file.filename or "upload",
|
||||
{"path": path, "overwrite": str(overwrite).lower(), "rel_path": rel_path},
|
||||
)
|
||||
except AgentError as exc:
|
||||
_raise(exc)
|
||||
finally:
|
||||
if os.path.exists(tmp.name):
|
||||
os.unlink(tmp.name)
|
||||
audit_service.record(
|
||||
session, user=user.username, action="agent.file.upload",
|
||||
target=f"{agent.name}:{path}", detail=rel_path or file.filename, ip=_ip(request),
|
||||
)
|
||||
return result
|
||||
|
||||
Reference in New Issue
Block a user