feat: v2.7.7 — File Explorer on workspaces and the user's home, clone with per-user git identity

- File Explorer (Files in the sidebar, /files; also from a workspace's Files
  tab, portalled so no panel clips it), laid out like GNOME Files: places
  (Home, workspaces, Trash), Back/Forward/Up, path bar, search, icon grid with
  image thumbnails or list, labelled New/Upload, selection action bar, hidden
  files behind Ctrl+H, right-click menus and the usual keys.
  Make, rename, move, copy (within a workspace, between workspaces, home ↔
  workspace), delete into a per-account trash restorable for 7 days, streamed
  upload of files and whole folders (≤ 500 MB a batch, Replace / Keep both /
  Skip on a taken name), download a file or a zip of anything.
  server/lib/file-explorer.js, /api/lanes/:id/fs/*, /api/homes/:who/fs/*.
- The user's home: every dot entry at its top stays hidden and refused
  (also via a symlink, download, zip or as a copy source); an admin may view
  other users' homes read-only, audited.
- write-as learns move/copy (re-run `sudo agenthub server install-system`).
- Hardening: a write to a workspace re-checks its owner by the live role;
  S3 keeps its server.conf if the file stops reading and a tmux call never
  falls back to the host's server; the AI Proxy gateway marks what it relays
  and the dashboard refuses it; a NUL in a pane's argv/env is refused.
- OpenAPI for the new routes (server/openapi-extra/files.js); docs (API,
  SERVER-MODE, CLAUDE.md) updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 14:17:39 +07:00
parent b441f32949
commit e94038481e
38 changed files with 5961 additions and 27 deletions
+167
View File
@@ -702,6 +702,8 @@ async function send<T>(path: string, options?: RequestInit): Promise<T> {
auth: body?.error?.auth as GitAuthNeeded | undefined,
// ESESSIONLIVE from a workspace start: the pane already holding it.
runId: body?.error?.run_id as string | undefined,
// EEXISTS from the File Explorer: the names already taken.
names: body?.error?.names as string[] | undefined,
});
}
// A 200 that is not JSON — the SPA's index.html from a proxy or a dev server
@@ -753,6 +755,54 @@ function gitUrl(target: GitTarget, sub: string, q?: URLSearchParams): string {
return `/lanes/${lane}/git${sub}${qs ? `?${qs}` : ""}`;
}
/** Where the File Explorer works: a workspace (as {@link GitTarget}) or a user's home. */
export type FsPlace = GitTarget | { home: string };
export const isHomePlace = (p: FsPlace): p is { home: string } =>
typeof p === "object" && "home" in p;
/** `/lanes/:id/fs<sub>` (same target as {@link gitUrl}) or `/homes/:who/fs<sub>`. */
function fsUrl(place: FsPlace, sub: string, q?: URLSearchParams): string {
if (isHomePlace(place)) {
const qs = new URLSearchParams(q).toString();
return `/homes/${encodeURIComponent(place.home)}/fs${sub}${qs ? `?${qs}` : ""}`;
}
return gitUrl(place, sub, q).replace(/^(\/lanes\/\d+)\/git/, "$1/fs");
}
/** A home the File Explorer can open (`GET /api/homes`): `me`, or another user's for an admin. */
export interface FsHome {
id: string;
label: string;
readOnly: boolean;
}
/** One entry of a File Explorer folder listing (`GET /api/lanes/:id/fs/list`). */
export interface FsEntry {
name: string;
/** Workspace-relative path. */
path: string;
/** `link` is a symlink; `linkdir` one to a folder inside the workspace. */
type: "dir" | "file" | "link" | "linkdir" | "other";
size: number | null;
mtime: number;
}
export interface FsListing {
path: string;
parent: string | null;
entries: FsEntry[];
truncated: boolean;
}
export interface FsTrashItem {
id: string;
name: string;
laneId: number | null;
path: string;
isDir: boolean;
deletedAt: string;
}
/** How a taken name is settled: ask (409 EEXISTS with `names`), skip, keep both, replace. */
export type FsConflict = "fail" | "skip" | "keep" | "replace";
/** The target for `laneId`: an extra folder's repo, else `session`'s worktree, else the workspace. */
export function gitTarget(
laneId: number,
@@ -2443,6 +2493,123 @@ export const api = {
{ method: "DELETE" }
),
},
/**
* The workspace File Explorer (`/api/lanes/:id/fs/*`, server/lib/file-explorer.js).
* Every write runs as the folder's account; a taken name answers 409
* EEXISTS with `names` unless `conflict` settles it.
*/
files: {
/** GET /api/homes — the homes this user may open (their own; others' read-only for an admin). */
homes: () => request<{ homes: FsHome[] }>("/homes"),
/** One file's download URL, on a workspace or a home. */
downloadUrl: (t: FsPlace, path: string) =>
isHomePlace(t)
? `${BASE}${fsUrl(t, "/download", new URLSearchParams({ path }))}`
: `${BASE}${gitUrl(t, "/download", new URLSearchParams({ path }))}`,
list: (t: FsPlace, path = "") =>
request<FsListing>(fsUrl(t, "/list", new URLSearchParams({ path }))),
mkdir: (t: FsPlace, path: string) =>
request<{ path: string }>(fsUrl(t, "/mkdir"), {
method: "POST",
body: JSON.stringify({ path }),
}),
newFile: (t: FsPlace, path: string) =>
request<{ path: string }>(fsUrl(t, "/newfile"), {
method: "POST",
body: JSON.stringify({ path }),
}),
rename: (t: FsPlace, path: string, name: string) =>
request<{ path: string }>(fsUrl(t, "/rename"), {
method: "POST",
body: JSON.stringify({ path, name }),
}),
move: (t: FsPlace, paths: string[], dest: string, conflict?: FsConflict) =>
request<{ moved: string[] }>(fsUrl(t, "/move"), {
method: "POST",
body: JSON.stringify({ paths, dest, conflict }),
}),
/** Copy into `t`'s `dest` — from `t` itself, or from workspace `fromLane`. */
copy: (
t: FsPlace,
paths: string[],
dest: string,
opts: { fromLane?: number; fromHome?: string; conflict?: FsConflict } = {}
) =>
request<{ copied: string[] }>(fsUrl(t, "/copy"), {
method: "POST",
body: JSON.stringify({ paths, dest, ...opts }),
}),
/** Into the account's trash (restorable). */
remove: (t: FsPlace, paths: string[]) =>
request<{ trashed: string[] }>(fsUrl(t, "/delete"), {
method: "POST",
body: JSON.stringify({ paths }),
}),
trash: (t: FsPlace) =>
request<{ items: FsTrashItem[]; keepDays?: number }>(fsUrl(t, "/trash")),
restore: (t: FsPlace, id: string) =>
request<{ path: string; laneId: number | null }>(fsUrl(t, "/trash/restore"), {
method: "POST",
body: JSON.stringify({ id }),
}),
purge: (t: FsPlace, id: string | "all") =>
request<{ purged: number }>(fsUrl(t, "/trash/purge"), {
method: "POST",
body: JSON.stringify({ id }),
}),
/** A zip of `paths` (files and folders), for a plain link/anchor download. */
zipUrl: (t: FsPlace, paths: string[]) => {
const q = new URLSearchParams();
for (const p of paths) q.append("p", p);
return `${BASE}${fsUrl(t, "/zip", q)}`;
},
/**
* Stream `file` to `path` (sub-folders made as needed), reporting upload
* progress. Rejects like {@link request}: `code`, and `names` on EEXISTS.
*/
upload: (
t: FsPlace,
path: string,
file: Blob,
conflict: FsConflict = "fail",
onProgress?: (sent: number) => void
) =>
new Promise<{ path: string; skipped?: boolean }>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", `${BASE}${fsUrl(t, "/upload", new URLSearchParams({ path, conflict }))}`);
xhr.setRequestHeader("Content-Type", "application/octet-stream");
const token = dashboardToken();
if (token) xhr.setRequestHeader("x-dashboard-token", token);
xhr.upload.onprogress = (e) => onProgress?.(e.loaded);
xhr.onerror = () => reject(Object.assign(new Error("network error"), { code: "ENET" }));
xhr.onload = () => {
let body: { error?: { code?: string; message?: string; names?: string[] } } & {
path?: string;
skipped?: boolean;
} = {};
try {
body = JSON.parse(xhr.responseText);
} catch {
/* not JSON */
}
if (xhr.status >= 200 && xhr.status < 300) {
resolve(body as { path: string; skipped?: boolean });
return;
}
if (xhr.status === 401 && body.error?.code === "EAUTH") {
window.dispatchEvent(new Event(AUTH_REQUIRED_EVENT));
}
reject(
Object.assign(new Error(body.error?.message || `HTTP ${xhr.status}`), {
code: body.error?.code,
status: xhr.status,
names: body.error?.names,
})
);
};
xhr.send(file);
}),
},
/** The signed-in user's own git identity (`/api/me/git`, `server/routes/git-identity.js`). */
gitIdentity: {
get: () => request<GitIdentity>("/me/git"),