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
+110
-1
@@ -39,6 +39,8 @@ from docker_client import DockerError, get_client, safe_call
|
||||
from services import (
|
||||
backup_service,
|
||||
compose_service,
|
||||
device_service,
|
||||
file_service,
|
||||
image_service,
|
||||
network_service,
|
||||
update_service,
|
||||
@@ -57,7 +59,7 @@ def _map_docker(exc: DockerError):
|
||||
raise HTTPException(status_code=code, detail=exc.detail or exc.error)
|
||||
raise exc # falls through to the global 502 DockerError handler
|
||||
|
||||
AGENT_VERSION = "0.14.0"
|
||||
AGENT_VERSION = "0.15.0"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -99,6 +101,34 @@ class ContainerRefBody(BaseModel):
|
||||
force: bool = False
|
||||
|
||||
|
||||
class FileWriteBody(BaseModel):
|
||||
path: str
|
||||
content: str
|
||||
|
||||
|
||||
class FileNameBody(BaseModel):
|
||||
path: str
|
||||
name: str
|
||||
|
||||
|
||||
class FileRenameBody(BaseModel):
|
||||
path: str
|
||||
new_name: str
|
||||
|
||||
|
||||
class FileTransferBody(BaseModel):
|
||||
src: str
|
||||
dest_dir: str
|
||||
overwrite: bool = False
|
||||
|
||||
|
||||
def _file_guard(fn, *args, **kwargs):
|
||||
try:
|
||||
return fn(*args, **kwargs)
|
||||
except file_service.BrowseError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Helpers
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -406,6 +436,85 @@ async def image_check() -> dict:
|
||||
return await update_service.check_all()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# File browser (sandboxed by this agent's ALLOWED_BROWSE_ROOTS/HOST_ROOT_PREFIX)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@app.get("/agent/files/list", dependencies=[Depends(verify_token)])
|
||||
def files_list(path: str = Query("/"), show_hidden: bool = Query(False)) -> dict:
|
||||
return _file_guard(device_service.browse, path, show_hidden)
|
||||
|
||||
|
||||
@app.get("/agent/files/read", dependencies=[Depends(verify_token)])
|
||||
def files_read(path: str = Query(...)) -> dict:
|
||||
return _file_guard(file_service.read_file, path)
|
||||
|
||||
|
||||
@app.get("/agent/files/download", dependencies=[Depends(verify_token)])
|
||||
def files_download(path: str = Query(...)):
|
||||
real, filename = _file_guard(file_service.resolve_download, path)
|
||||
return FileResponse(real, filename=filename, media_type="application/octet-stream")
|
||||
|
||||
|
||||
@app.put("/agent/files/write", dependencies=[Depends(verify_token)])
|
||||
def files_write(body: FileWriteBody) -> dict:
|
||||
return _file_guard(file_service.write_file, body.path, body.content)
|
||||
|
||||
|
||||
@app.post("/agent/files/mkdir", dependencies=[Depends(verify_token)])
|
||||
def files_mkdir(body: FileNameBody) -> dict:
|
||||
return _file_guard(file_service.create_dir, body.path, body.name)
|
||||
|
||||
|
||||
@app.post("/agent/files/touch", dependencies=[Depends(verify_token)])
|
||||
def files_touch(body: FileNameBody) -> dict:
|
||||
return _file_guard(file_service.create_file, body.path, body.name)
|
||||
|
||||
|
||||
@app.post("/agent/files/rename", dependencies=[Depends(verify_token)])
|
||||
def files_rename(body: FileRenameBody) -> dict:
|
||||
return _file_guard(file_service.rename, body.path, body.new_name)
|
||||
|
||||
|
||||
@app.post("/agent/files/copy", dependencies=[Depends(verify_token)])
|
||||
def files_copy(body: FileTransferBody) -> dict:
|
||||
return _file_guard(file_service.copy, body.src, body.dest_dir, body.overwrite)
|
||||
|
||||
|
||||
@app.post("/agent/files/move", dependencies=[Depends(verify_token)])
|
||||
def files_move(body: FileTransferBody) -> dict:
|
||||
return _file_guard(file_service.move, body.src, body.dest_dir, body.overwrite)
|
||||
|
||||
|
||||
@app.delete("/agent/files", dependencies=[Depends(verify_token)])
|
||||
def files_delete(path: str = Query(...), recursive: bool = Query(False)) -> dict:
|
||||
return _file_guard(file_service.delete, path, recursive)
|
||||
|
||||
|
||||
@app.post("/agent/files/upload", dependencies=[Depends(verify_token)])
|
||||
async def files_upload(
|
||||
path: str = Form(...),
|
||||
overwrite: bool = Form(False),
|
||||
rel_path: str = Form(""),
|
||||
file: UploadFile = File(...),
|
||||
) -> dict:
|
||||
real = _file_guard(
|
||||
file_service.upload_target, path, file.filename or "", overwrite, rel_path or None
|
||||
)
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False, dir=os.path.dirname(real))
|
||||
try:
|
||||
while chunk := await file.read(1024 * 1024):
|
||||
tmp.write(chunk)
|
||||
tmp.close()
|
||||
os.replace(tmp.name, real)
|
||||
except OSError as exc:
|
||||
if os.path.exists(tmp.name):
|
||||
os.unlink(tmp.name)
|
||||
raise HTTPException(status_code=400, detail=f"Upload failed: {exc}") from exc
|
||||
return {"ok": True, "name": rel_path or file.filename}
|
||||
|
||||
|
||||
@app.websocket("/agent/ws/logs/{stack_id}")
|
||||
async def ws_logs(websocket: WebSocket, stack_id: str, token: str | None = Query(default=None)):
|
||||
"""Stream `docker compose logs -f` to the central app (token via query param)."""
|
||||
|
||||
Reference in New Issue
Block a user