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
+26 -22
View File
@@ -20,52 +20,56 @@ function triggerDownload(blob: Blob, filename: string) {
URL.revokeObjectURL(url);
}
// Base path for the local host or, when agentId is given, a remote agent.
const base = (agentId?: number) =>
agentId != null ? `/api/agents/${agentId}/files` : "/api/files";
export const filesApi = {
list: (path: string, showHidden = false) =>
list: (path: string, showHidden = false, agentId?: number) =>
api
.get<HostPathResult>("/api/files/list", { params: { path, show_hidden: showHidden } })
.get<HostPathResult>(`${base(agentId)}/list`, { params: { path, show_hidden: showHidden } })
.then((r) => r.data),
read: (path: string) =>
api.get<FileContent>("/api/files/read", { params: { path } }).then((r) => r.data),
read: (path: string, agentId?: number) =>
api.get<FileContent>(`${base(agentId)}/read`, { params: { path } }).then((r) => r.data),
write: (path: string, content: string) =>
api.put<{ path: string; size: number }>("/api/files/write", { path, content }).then((r) => r.data),
write: (path: string, content: string, agentId?: number) =>
api.put<{ path: string; size: number }>(`${base(agentId)}/write`, { path, content }).then((r) => r.data),
mkdir: (path: string, name: string) =>
api.post<{ path: string }>("/api/files/mkdir", { path, name }).then((r) => r.data),
mkdir: (path: string, name: string, agentId?: number) =>
api.post<{ path: string }>(`${base(agentId)}/mkdir`, { path, name }).then((r) => r.data),
touch: (path: string, name: string) =>
api.post<{ path: string }>("/api/files/touch", { path, name }).then((r) => r.data),
touch: (path: string, name: string, agentId?: number) =>
api.post<{ path: string }>(`${base(agentId)}/touch`, { path, name }).then((r) => r.data),
rename: (path: string, newName: string) =>
api.post<{ path: string }>("/api/files/rename", { path, new_name: newName }).then((r) => r.data),
rename: (path: string, newName: string, agentId?: number) =>
api.post<{ path: string }>(`${base(agentId)}/rename`, { path, new_name: newName }).then((r) => r.data),
remove: (path: string, recursive = false) =>
api.delete("/api/files", { params: { path, recursive } }).then((r) => r.data),
remove: (path: string, recursive = false, agentId?: number) =>
api.delete(base(agentId), { params: { path, recursive } }).then((r) => r.data),
copy: (src: string, destDir: string, overwrite = false) =>
copy: (src: string, destDir: string, overwrite = false, agentId?: number) =>
api
.post<{ path: string }>("/api/files/copy", { src, dest_dir: destDir, overwrite })
.post<{ path: string }>(`${base(agentId)}/copy`, { src, dest_dir: destDir, overwrite })
.then((r) => r.data),
move: (src: string, destDir: string, overwrite = false) =>
move: (src: string, destDir: string, overwrite = false, agentId?: number) =>
api
.post<{ path: string }>("/api/files/move", { src, dest_dir: destDir, overwrite })
.post<{ path: string }>(`${base(agentId)}/move`, { src, dest_dir: destDir, overwrite })
.then((r) => r.data),
download: async (path: string, filename: string) => {
const res = await api.get("/api/files/download", { params: { path }, responseType: "blob" });
download: async (path: string, filename: string, agentId?: number) => {
const res = await api.get(`${base(agentId)}/download`, { params: { path }, responseType: "blob" });
triggerDownload(res.data as Blob, filename);
},
upload: async (path: string, file: File, overwrite = false, relPath = "") => {
upload: async (path: string, file: File, overwrite = false, relPath = "", agentId?: number) => {
const form = new FormData();
form.append("path", path);
form.append("overwrite", String(overwrite));
if (relPath) form.append("rel_path", relPath);
form.append("file", file);
const res = await api.post<{ ok: boolean; name: string }>("/api/files/upload", form);
const res = await api.post<{ ok: boolean; name: string }>(`${base(agentId)}/upload`, form);
return res.data;
},
};