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:
menzelj
2026-06-08 12:04:56 +00:00
co-authored by Claude Opus 4.8
parent 012614f5fb
commit c5f591749f
7 changed files with 428 additions and 39 deletions
+110 -1
View File
@@ -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)."""
+1 -1
View File
@@ -55,7 +55,7 @@ async def lifespan(app: FastAPI):
schedule_task.cancel()
app = FastAPI(title="StackPilot", version="0.14.0", lifespan=lifespan)
app = FastAPI(title="StackPilot", version="0.15.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
+211
View File
@@ -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