diff --git a/CLAUDE.md b/CLAUDE.md
index 85634cd2..5b0036d3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,6 +1,6 @@
# Claude Code Working Guide
-_Last synced: v2.7.6 (2026-10-14) — Clone (https/ssh) with per-user git identity; Files tab upload/download + non-git tree; pinned sessions; text size._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
+_Last synced: v2.7.7 (2026-10-14) — File Explorer (workspaces + the user's home, trash), clone + per-user git identity, S3 fails closed._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
## Project mission
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
@@ -56,6 +56,7 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which
- **Adopted lanes are not destroyable.** A lane with `kind === "adopted"` may never have its worktree reset or removed; "removing" one only drops the dashboard's own record of it. This is enforced in code (`assertDestroyable`'s first check) — do not add a path that bypasses it.
- **Removing a lane deletes the lane's RECORD and nothing else — for every kind, managed included.** `POST /api/lanes/:id/remove` stops the run and the stack, clears AgentHub's own runtime bookkeeping under `LANES_ROOT/.state/lane/`, and deletes the row. It calls neither `removeWorktree` nor `removeLaneData`: the working tree, its branch and the lane's database are the USER's data, and a mis-click on a dashboard row may never cost a branch's unpushed code. Deleting those is the user's own `git worktree remove` / `git branch -d` / db drop. Consequently `remove` has no unpushed-commits force-gate (only `reset` does) and no preflight blocker stops it. Do not add a "also delete the worktree" flag back onto this route. (A session's own `claude --worktree` tree is a different thing with its own guarded route — see the session-worktree rule below; a lane's folder is never it.)
- **A lane can hold up to ten tmux sessions (slots 0–9; the Workspace groups them into tabs of up to four split panes) — and only slot 0 is the lane's run.** `agenthub-lane-` is slot 0 and the only one `run_id`/`status` ever record; `agenthub-lane--<1-9>` are extra sessions on the same lane (`MAX_PANE_SLOT` in `server/lib/pty-run.js`; the `-[1-9]` run id pattern is repeated in `pty-attach.js`, `websocket.js`, `routes/hooks.js` and the client — widen them together). Anything that must stop a lane's agents (`stop`, `reset`, `remove`) goes through `laneRunIds` so no slot survives it; anything that names "the lane's pane" (`agenthub lanes shell`) means slot 0. A client that wants "a new tab" sends `slot: "auto"` and the SERVER picks the first free slot under the lane lock (`409 ERUNLIVE` when all ten are busy) — never a slot computed from the client's own list, which may already be stale.
+- **The File Explorer (`server/lib/file-explorer.js`, `/api/lanes/:id/fs/*`) reuses the read path's guards and never replaces silently.** Every path is `lanePath` + the PARENT `resolveInLane`-confined with the last segment kept as a name (so a symlink is moved/deleted as a link, never followed); never in or under `.git`, never a secret, never the workspace folder itself. Writes run as the folder's account — a member's only through `write-as` (`move`/`copy` modes; `writeAsAsync` streams an upload to its stdin) — and a move/copy never crosses accounts (`sameOwner`, `EFOREIGN`). A taken name is settled by `conflict` (`fail` → 409 with `names`, `skip`, `keep`, `replace` = the old one to the trash first). Delete is a move into the account's trash (member: `~/.local/share/agenthub/trash/`, host: `/trash//`), restorable 7 days. `write-as` is root-installed: a change to it needs `sudo agenthub server install-system` again (`EUPGRADE` until then). A user's HOME is browsed through the same module inside `inHome` (`/api/homes/:who/fs/*`, `server/lib/homes.js` picks the folder): every dot entry at the TOP of a home stays hidden and refused — through a symlink, a download, a zip or as a copy source too — and another user's home is an admin's READ-ONLY, audited view (`EREADONLY`). Do not add a delete that skips the trash, an overwrite without `conflict`, or a request path that is not run through `locate`/`folder`.
- **The whole-file editor and the Files tab's upload are the two non-git writes on the lane file surface, and both live outside `git-ops.js` on purpose.** `git-ops.js` only touches paths `git status` reports as changed; `lane-file-write.js` may save any existing text file, so it must reuse the READ path's guards (`lanePath`, `resolveInLane`, `isSecretOnDisk`) and never be weaker than them, and it saves only over the `baseSha` it was opened at — do not let it create files or touch a binary. `lane-file-transfer.js`'s upload (`POST /api/lanes/:id/git/upload`, issue #78) is the opposite and only that: it CREATES one file (any bytes, ≤ 25 MB) under the same guards, into an existing folder, refuses a taken name (`EEXISTS`, a symlink included) and anything into or named `.git` (a hook there runs on the dashboard's next git call), and writes as the folder's member through `write-as` under S3 — do not let it overwrite, make folders or take a path from anything but `dir` + one plain name. Its `GET …/git/download` twin streams any file through `openSafe` as a `sandbox`ed attachment. The tree, expand-all and quick-open read the disk (`walkFolder`) for a folder that is not a repo.
- **Server tests never touch the user's tmux.** Run them through `npm run test:server` (`scripts/test-server.js`), never a bare `node --test server/__tests__/*.test.js` from inside a lane pane — the suites create and kill real `agenthub-lane-` sessions.
- **A workspace's instructions, extra folders and context files are lane facts, and a pane reads them only from the lane's rows.** The one addition is the workspace OWNER's own agent instructions (`user_prefs.instructions`, `PUT /api/me/instructions`, Settings → AI tools), which `launchExtras` puts first — keyed by `lanes.owner_id` (`local` in local mode), never by whoever clicked Start. `lanes.instructions` and `lane_sources` (`server/lib/lane-sources.js`) reach a pane through `launchExtras(laneId, tool)`, which `spawnRun` calls ITSELF — so neither spawn path can take them from a request — and every write is same-origin (`server/routes/lane-sources.js`). A folder must be absolute, exist, be a directory, not be or contain the OS home (`validateHomeDir`) and not be the workspace's own folder; it is the user's, and AgentHub never deletes it. An uploaded file is stored under `/workspace-context//` under a name AgentHub derives (never a path from the request), and those copies are the only thing here AgentHub deletes (`ownsFile`): with their row, and with the workspace in `deleteLane`. Claude's `--add-dir` is VARIADIC, so it goes first in the argv where `--permission-mode` ends it, and the text (instructions + context file list + extra folders) is written to `/workspace-prompts/.md` at every spawn and passed as `--append-system-prompt-file=` (one `=` argv element, so nothing in it is read as an option); Codex gets one `--add-dir` per directory and `-c developer_instructions=` — the text itself up to 6 KB, else a line pointing at that file. Only `claude`/`codex` take them. Never write them into the user's repo (CLAUDE.md/AGENTS.md).
@@ -78,7 +79,7 @@ See `docs/LANES.md` for full guide: stage reporting, custom templates, lane acti
- **A read that scans the events table never runs on the event loop, and never runs a correlated subquery per event.** Node serves every page, hook and websocket from one thread, and better-sqlite3/node:sqlite block it for the whole query — one slow aggregate freezes the dashboard for every user. Put an aggregate that takes more than a few hundred ms at real size into `JOBS` in `server/lib/read-pool.js` (its own `query_only` connection in a worker, timeout, shared in-flight runs); write "the next event of the session" as a window function (`LEAD() OVER (PARTITION BY session_id ORDER BY id)`), not `MIN(id) … id > e1.id`. And do not drop `idx_events_session(session_id, id)` as a "prefix" of another index: only it orders a session's events by id.
- **A headless entry point requires `server/lib/windows-hide.js` before anything reads `child_process`.** The dashboard and its detached helpers have no console on Windows, so every git/npm call without `windowsHide` opened a console window that flashed and stole focus on each poll. `install()` (in `server/index.js`, `plugin-bootstrap.js`, `plugin-update-helper.js`, `cc-update-all-helper.js`, `restart-helper.js`) forces it for every spawn; a new detached script needs the same first line, or a `windowsHide: true` of its own (the statusline's one git call has that).
- **Server mode is a MODE of this codebase, never a fork, and local mode must not notice it.** Everything server-only checks `isServerMode()` (`AGENTHUB_MODE=server`, `server/lib/auth.js`) and is a no-op otherwise; `auth.js` opens the database only on first use, so a module that merely loads it never touches the DB in local mode. Accounts are made from the host's shell (`agenthub server setup`, `agenthub users …`) or by an admin on the Users page (`server/routes/users.js`), where EVERY change needs the admin's own password again (`confirmPassword` in `auth.js`, throttled — EREAUTH/ETOOMANY), never lets an admin demote or disable themselves (ESELF), is written to `audit_log`, and refuses in local mode (ELOCALMODE: no login to check) — do not add an account route without that step-up, and never one that returns a password hash. Rows are OWNED (phase S2, `server/lib/scope.js`): `lanes.owner_id` is the creator (never from the request — `ownerFor(req)`), a session belongs to its workspace (`sessions.lane_id` or a `dashboard_runs` row), and everything else hangs off the session. A `developer` sees only their own rows, and an `analyst` reads every row but writes only their own (`memberId` scopes an analyst's non-GET requests like a developer's — `member` was renamed `developer`, `db.js` migrates it): every list/aggregate passes `req` into `sessionWhere`/`bySessionWhere`/`laneWhere`; every route naming ONE row is covered by `scopeGuard`'s `PATH_RULES` + the query/body id names in `namedIds` (404 `ENOTFOUND`) — a new id route under a new prefix needs a rule there; every WebSocket broadcast type needs a `broadcastAudience` case (an unlisted type reaches no developer); a staff-wide view joins `STAFF_ONLY_PREFIXES`. Admin/analyst reads of a developer's session are written to `audit_log` (`server/lib/audit.js`). A pane carries `AGENTHUB_PANE_TOKEN` (hash in `dashboard_runs.pane_token_hash`, never listed) so the CLI/MCP/statusline inside it act as the workspace owner — accepted only from loopback and only while the pane lives (`userFromPaneToken`). Every write in server mode passes the Origin check in `authGuard`; prefix checks go through `apiPath()` (decoded, case-insensitive), because Express routes `/api/Webhooks` and `%77ebhooks` too. Named locks are namespaced per user, and `named-lock.js` takes only a plain name (a lock name was a path). It is NOT OS isolation unless S3 is installed (next rule); without it every pane runs as the host account, so do not call that multi-tenant. A new route that writes the host's own configuration joins `ADMIN_WRITE_PREFIXES`, and its controls hide behind `usePermissions().isAdmin` (`client/src/components/AuthGate.tsx`) — a hidden button is a courtesy, the server's refusal is the rule; hooks stay loopback-only in server mode.
-- **With S3 installed (server mode + `/etc/agenthub/server.conf`, `s3Enabled`), a pane runs as its workspace owner's Linux user, and the dashboard reaches a member only through sudo.** `ah-`, home `/`; the FOLDER decides (`accountForPath` in `server/lib/run-as.js`), and a folder under the users root with no enabled, provisioned user is `EMEMBERGONE` — never fall back to the host there. Every tmux call on a member's socket (attach included), every git/`gh` call in a member's folder and every write is `sudo -n -u ah- ` by the absolute paths in `server.conf` (`sudoArgv`); sudoers allows exactly those, plus `/opt/agenthub/tools/bin/claude` for the opt-in plugin baseline (`applyPluginBaseline`), as `%agenthub-members` and NEVER root as a run-as target. **The only root commands are `/opt/agenthub/sbin/agenthub-provision` (create/disable/enable — it never deletes, never adopts a non-member account, and root never touches a path inside an existing home) and `systemctl start|stop|restart|enable|disable agenthub.service`;** do not add a third — a host compromise is already every member's. **sudo logs argv, so nothing secret or private crosses it:** a member pane's env (pane token, pool token) and command (prompt) go in a launch file written by `write-as` from stdin and run by `pane-launch`; file content always goes on stdin. **The host never writes, chmods or deletes inside a member's home itself — only through `write-as` as the member** (`writeFileAs`, `handoffWrite`, `mkdirAs`, `removeAs`, `scratchFile`): the member can swap any name there for a link, and the host's write would follow it. A member's file is READ only through `openSafe`/`readFileSafe*`/`readStreamSafe` (open, then `fstat`: a regular file that member owns, `O_NONBLOCK`), never a plain `fs` read. **Hooks are authenticated by the peer uid** of the loopback connection (`/proc/net/tcp*`, `server/lib/hook-auth.js`), never by anything in the body: host or root = trusted, an enabled `ah-*` = only work, sessions, runs and transcript paths inside its own home (`EFOREIGN`, foreign paths dropped BEFORE any tool mapping reads them), anyone else `EPEER`. **`hidepid=invisible` (gid `agenthub-proc` exempt) is required** — panes' argv/env would otherwise be readable by every account — and `install-system` stops without it. The unit's ambient `CAP_DAC_READ_SEARCH` is dropped for every child (`server/lib/drop-caps.js`, installed first in `server/index.js`, before anything reads `child_process`) — keep it there, and a spawn outside `child_process` (node-pty) takes `ptyArgv`. **The account comes from a path's TEXT, so a path a member can redirect is checked before it is used:** a workspace's real path (`laneRoot` in `git-diff.js`, `EFOREIGN`) and a git dir their git prints (`git-exclude.js`) must stay the same account's (`sameOwner`), a member's home is no workspace, and a READ uses `ownerForRead`, never the throwing `accountForPath`. **A workspace inside a member's home is owned by that member only, and one outside the users root (it runs as the HOST) by an admin only, by the LIVE role** (`assertOwnerFits`: create, assign, every pane start — `EOWNERFOLDER`; demoting or disabling a user ends their host panes): its panes run as the FOLDER's account but carry the OWNER's pane token, which that account can read; a pane's cwd must be that same account's (`ECWDACCOUNT`), and a pane token is accepted only from that account's uid (`peerIsPaneAccount`). A path a non-admin names in ANY request (an analyst's GET too) stays in their own folder or workspaces (`ownFolder`, `insideOwnWorkspace`, `scope.ownCwdQuery`), and the config `file` reads never serve a credential file or anything in the data dir (`isSecretConfigPath`). **A member can stall or flood what the dashboard reads, and it all runs on one thread:** every synchronous sudo into a member is time-bounded (`MEMBER_EXEC`, tmux `memberCall` with a back-off) and a member's tmux failure makes only THEIR panes unknown, never everyone's; a member's file is read with a size cap (`readFileSafe*` `max`), line by line only through `readLines` (never `readline`, which a one-line 512 MB file crashes), and a sparse one is never copied out as real blocks. Operator steps: `docs/SERVER-MODE.md` § Operator runbook.
+- **With S3 installed (server mode + `/etc/agenthub/server.conf`, `s3Enabled`), a pane runs as its workspace owner's Linux user, and the dashboard reaches a member only through sudo.** `ah-`, home `/`; the FOLDER decides (`accountForPath` in `server/lib/run-as.js`), and a folder under the users root with no enabled, provisioned user is `EMEMBERGONE` — never fall back to the host there. Every tmux call on a member's socket (attach included), every git/`gh` call in a member's folder and every write is `sudo -n -u ah- ` by the absolute paths in `server.conf` (`sudoArgv`); sudoers allows exactly those, plus `/opt/agenthub/tools/bin/claude` for the opt-in plugin baseline (`applyPluginBaseline`), as `%agenthub-members` and NEVER root as a run-as target. **The only root commands are `/opt/agenthub/sbin/agenthub-provision` (create/disable/enable — it never deletes, never adopts a non-member account, and root never touches a path inside an existing home) and `systemctl start|stop|restart|enable|disable agenthub.service`;** do not add a third — a host compromise is already every member's. **sudo logs argv, so nothing secret or private crosses it:** a member pane's env (pane token, pool token) and command (prompt) go in a launch file written by `write-as` from stdin and run by `pane-launch`; file content always goes on stdin. **The host never writes, chmods or deletes inside a member's home itself — only through `write-as` as the member** (`writeFileAs`, `handoffWrite`, `mkdirAs`, `removeAs`, `scratchFile`): the member can swap any name there for a link, and the host's write would follow it. A member's file is READ only through `openSafe`/`readFileSafe*`/`readStreamSafe` (open, then `fstat`: a regular file that member owns, `O_NONBLOCK`), never a plain `fs` read. **Hooks are authenticated by the peer uid** of the loopback connection (`/proc/net/tcp*`, `server/lib/hook-auth.js`), never by anything in the body: host or root = trusted, an enabled `ah-*` = only work, sessions, runs and transcript paths inside its own home (`EFOREIGN`, foreign paths dropped BEFORE any tool mapping reads them), anyone else `EPEER`. **`hidepid=invisible` (gid `agenthub-proc` exempt) is required** — panes' argv/env would otherwise be readable by every account — and `install-system` stops without it. The unit's ambient `CAP_DAC_READ_SEARCH` is dropped for every child (`server/lib/drop-caps.js`, installed first in `server/index.js`, before anything reads `child_process`) — keep it there, and a spawn outside `child_process` (node-pty) takes `ptyArgv`. **The account comes from a path's TEXT, so a path a member can redirect is checked before it is used:** a workspace's real path (`laneRoot` in `git-diff.js`, `EFOREIGN`) and a git dir their git prints (`git-exclude.js`) must stay the same account's (`sameOwner`), a member's home is no workspace, and a READ uses `ownerForRead`, never the throwing `accountForPath`. **A workspace inside a member's home is owned by that member only, and one outside the users root (it runs as the HOST) by an admin only, by the LIVE role** (`assertOwnerFits`: create, assign, every pane start — `EOWNERFOLDER`; demoting or disabling a user ends their host panes): its panes run as the FOLDER's account but carry the OWNER's pane token, which that account can read; a pane's cwd must be that same account's (`ECWDACCOUNT`), and a pane token is accepted only from that account's uid (`peerIsPaneAccount`). A path a non-admin names in ANY request (an analyst's GET too) stays in their own folder or workspaces (`ownFolder`, `insideOwnWorkspace`, `scope.ownCwdQuery`), and the config `file` reads never serve a credential file or anything in the data dir (`isSecretConfigPath`). **A member can stall or flood what the dashboard reads, and it all runs on one thread:** every synchronous sudo into a member is time-bounded (`MEMBER_EXEC`, tmux `memberCall` with a back-off) and a member's tmux failure makes only THEIR panes unknown, never everyone's; a member's file is read with a size cap (`readFileSafe*` `max`), line by line only through `readLines` (never `readline`, which a one-line 512 MB file crashes), and a sparse one is never copied out as real blocks. **S3 fails closed, never to the host:** `serverConf` keeps the last good `server.conf` in server mode if the file stops reading, `tmux.accountOf` throws (never "the host's server") when it cannot tell a session's account under S3, and every non-GET request naming a workspace re-checks `assertOwnerFits` by the LIVE role (`scopeGuard`, `409 EOWNERFOLDER`) — a demoted admin keeps READING their host-folder workspaces but `/up`, `/hook`, a commit or a file write there no longer runs as the host. The AI Proxy gateway marks what it relays (`x-agenthub-via-gateway`) and the dashboard refuses any request so marked (`403 EGATEWAY`), since a personal profile may point back at it from our own trusted uid; a member's launch file refuses a NUL in argv/env (`EBADARG`). Operator steps: `docs/SERVER-MODE.md` § Operator runbook.
- **A pool token reaches a pane only through tmux `-e` (a member's pane under S3: its launch file), and only for the account `pickAccount` chose.** Never type it into a pane, never put it in the CLI's argv, never return it from a route (`poolStatus` has no token field), and never add a route that reads one back — the pool is filled from the host's shell (`agenthub accounts add [--tool codex]`) or by an admin on the Users page (`POST`/`DELETE /api/accounts`, `/accounts` in `ADMIN_WRITE_PREFIXES`, their own password again, audited by account NAME), where the token only goes in. An empty pool must leave every spawn exactly as before. The two exceptions are a Claude login (`server/lib/claude-pool.js` kind `login`, moved in from a saved switch-user profile — the host CLI's own moves with its live login, and the host's home becomes one more placement — or a pasted `.credentials.json`) and a Codex ChatGPT login (`server/lib/codex-pool.js`), which neither CLI reads from an env var: it is written as `auth.json` into a MEMBER's own CODEX_HOME, as the member (`write-as`, 0600), never into a host pane's CODEX_HOME, never over a login AgentHub did not place, and never a second account into a home where a live pane of that member runs on one; `syncBack` copies the newest back. A Claude login's refresh token is single use, so the server is its one refresher (`refreshLogins`, ahead of expiry, pushing the new pair into every home it placed it in), and a pair a member's Claude refreshed first is copied back only once Anthropic confirms the same account (`fetchOwner`). A Codex API key is `-e AGENTHUB_POOL_KEY` plus a `-c` provider whose `env_key` only names it.
- Preserve existing behavior unless explicitly asked to change it.
- Prefer minimal, reversible diffs.
diff --git a/client/src/App.tsx b/client/src/App.tsx
index 8b143a2e..5d8ee5e1 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -87,6 +87,7 @@ const SessionDetail = lazy(() =>
const SnapshotView = lazy(() =>
import("./pages/SnapshotView").then((m) => ({ default: m.SnapshotView }))
);
+const FilesPage = lazy(() => import("./pages/FilesPage").then((m) => ({ default: m.FilesPage })));
const Users = lazy(() => import("./pages/Users").then((m) => ({ default: m.Users })));
function PageFallback() {
@@ -210,6 +211,14 @@ export default function App() {
}
/>
+
+
+
+ }
+ />
+ */
+
+import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
+import type {
+ DragEvent as ReactDragEvent,
+ KeyboardEvent as ReactKeyboardEvent,
+ MouseEvent as ReactMouseEvent,
+ ReactNode,
+} from "react";
+import { createPortal } from "react-dom";
+import { useTranslation } from "react-i18next";
+import {
+ ArrowLeft,
+ ArrowRight,
+ ArrowUp,
+ ChevronDown,
+ ChevronRight,
+ ClipboardPaste,
+ Copy,
+ Download,
+ EllipsisVertical,
+ Eye,
+ File as FileIcon,
+ FileArchive,
+ FileAudio,
+ FileCode,
+ FileImage,
+ FileJson,
+ FileSpreadsheet,
+ FileText,
+ FileVideo,
+ Folder,
+ FolderGit2,
+ FolderSymlink,
+ House,
+ LayoutGrid,
+ Link2,
+ List,
+ Pencil,
+ Plus,
+ RotateCcw,
+ Scissors,
+ Search,
+ Trash2,
+ Upload,
+ UserRound,
+ X,
+} from "lucide-react";
+import {
+ api,
+ gitTarget,
+ type FsConflict,
+ type FsEntry,
+ type FsListing,
+ type FsTrashItem,
+ type FsHome,
+ type FsPlace,
+ isHomePlace,
+} from "../../lib/api";
+import type { Lane } from "../../lib/types";
+import { useDialog } from "../../hooks/useDialog";
+import { toast } from "../../lib/toast";
+import { formatBytes } from "../../lib/format";
+import { copyText } from "../../lib/clipboard";
+import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
+
+/** One upload batch at most (the server caps one file the same). */
+export const FX_BATCH_MAX_BYTES = 500 * 1024 * 1024;
+const MIME = "application/x-agenthub-fx";
+const VIEW_KEY = "agenthub.files.view";
+const HIDDEN_KEY = "agenthub.files.hidden";
+
+type Clip = { place: FsPlace; paths: string[]; cut: boolean };
+// The clipboard outlives the dialog and a workspace switch, like a desktop's.
+let clipboard: Clip | null = null;
+
+/** The workspace id of a place, or null for a home. */
+const laneOf = (t: FsPlace) => (isHomePlace(t) ? null : typeof t === "number" ? t : t.lane);
+/** A workspace's own folder (not a session worktree or an extra folder), or a home. */
+const wholePlace = (t: FsPlace) => isHomePlace(t) || typeof t === "number";
+const samePlace = (a: FsPlace, b: FsPlace) => JSON.stringify(a) === JSON.stringify(b);
+const join = (dir: string, name: string) => (dir ? `${dir}/${name}` : name);
+const errText = (e: unknown) => (e instanceof Error ? e.message : String(e));
+const errCode = (e: unknown) => (e as { code?: string })?.code;
+const isFolder = (e: FsEntry) => e.type === "dir" || e.type === "linkdir";
+const ext = (name: string) => name.slice(name.lastIndexOf(".") + 1).toLowerCase();
+const IMAGE = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico"]);
+const CODE = new Set([
+ "js",
+ "jsx",
+ "ts",
+ "tsx",
+ "mjs",
+ "cjs",
+ "py",
+ "go",
+ "rs",
+ "java",
+ "kt",
+ "c",
+ "h",
+ "cpp",
+ "cs",
+ "rb",
+ "php",
+ "sh",
+ "css",
+ "scss",
+ "html",
+ "vue",
+ "svelte",
+ "sql",
+ "yml",
+ "yaml",
+ "toml",
+ "xml",
+]);
+
+function readPref(key: string, fallback: string) {
+ try {
+ return localStorage.getItem(key) ?? fallback;
+ } catch {
+ return fallback;
+ }
+}
+function writePref(key: string, value: string) {
+ try {
+ localStorage.setItem(key, value);
+ } catch {
+ /* a per-browser convenience only */
+ }
+}
+
+/** Every file under a dropped folder entry, with its path inside the drop. */
+async function walkEntry(
+ entry: FileSystemEntry,
+ prefix: string,
+ out: { file: File; rel: string }[]
+) {
+ if (entry.isFile) {
+ const file = await new Promise((ok, no) => (entry as FileSystemFileEntry).file(ok, no));
+ out.push({ file, rel: `${prefix}${entry.name}` });
+ return;
+ }
+ const reader = (entry as FileSystemDirectoryEntry).createReader();
+ // readEntries answers in pages until it answers [].
+ for (;;) {
+ const page = await new Promise((ok, no) => reader.readEntries(ok, no));
+ if (!page.length) break;
+ for (const child of page) await walkEntry(child, `${prefix}${entry.name}/`, out);
+ }
+}
+
+/** The icon a desktop would show for this entry. */
+function EntryIcon({ entry, size }: { entry: FsEntry; size: "lg" | "sm" }) {
+ const cls = size === "lg" ? "h-12 w-12" : "h-4 w-4";
+ if (entry.type === "dir")
+ return ;
+ if (entry.type === "linkdir") return ;
+ if (entry.type === "link") return ;
+ const e = ext(entry.name);
+ const Icon = IMAGE.has(e)
+ ? FileImage
+ : ["zip", "gz", "tgz", "tar", "rar", "7z", "bz2", "xz"].includes(e)
+ ? FileArchive
+ : ["json", "jsonc"].includes(e)
+ ? FileJson
+ : ["csv", "xls", "xlsx", "ods", "tsv"].includes(e)
+ ? FileSpreadsheet
+ : ["mp4", "mov", "webm", "mkv", "avi"].includes(e)
+ ? FileVideo
+ : ["mp3", "wav", "ogg", "flac", "m4a"].includes(e)
+ ? FileAudio
+ : ["md", "txt", "rst", "log", "pdf", "doc", "docx"].includes(e)
+ ? FileText
+ : CODE.has(e)
+ ? FileCode
+ : FileIcon;
+ return ;
+}
+
+type Ask = {
+ names: string[];
+ many: boolean;
+ resolve: (r: { choice: FsConflict | null; all: boolean }) => void;
+};
+
+/** The file manager; fills its parent. `onClose` adds a close button (the dialog). */
+export function FileBrowser({
+ laneId,
+ initialHome,
+ session,
+ source,
+ initialPath = "",
+ onChanged,
+ onClose,
+ onNavigate,
+}: {
+ /** A workspace to open on; else `initialHome`. */
+ laneId?: number | null;
+ /** A home to open on (`me`, or another user's for an admin). */
+ initialHome?: string | null;
+ session?: string | null;
+ source?: number | null;
+ initialPath?: string;
+ /** Called after anything changed files, so a tree elsewhere can re-read. */
+ onChanged?: () => void;
+ onClose?: () => void;
+ /** The page keeps `?lane=` / `?home=` and `&path=` in the URL. */
+ onNavigate?: (place: FsPlace, path: string) => void;
+}) {
+ const { t } = useTranslation(["lanes"]);
+ const placeOf = (): FsPlace =>
+ laneId != null ? gitTarget(laneId, session, source) : { home: initialHome || "me" };
+ const [target, setTarget] = useState(placeOf);
+ const [homes, setHomes] = useState([]);
+ const [path, setPathState] = useState(initialPath);
+ const [history, setHistory] = useState<{ back: string[]; fwd: string[] }>({ back: [], fwd: [] });
+ const [listing, setListing] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [selected, setSelected] = useState>(new Set());
+ const anchor = useRef(null);
+ const [view, setView] = useState<"files" | "trash">("files");
+ const [layout, setLayout] = useState<"grid" | "list">(() =>
+ readPref(VIEW_KEY, "grid") === "list" ? "list" : "grid"
+ );
+ const [showHidden, setShowHidden] = useState(() => readPref(HIDDEN_KEY, "0") === "1");
+ const [query, setQuery] = useState(null);
+ const [trash, setTrash] = useState([]);
+ const [busy, setBusy] = useState(false);
+ const [renaming, setRenaming] = useState<{ path: string; value: string } | null>(null);
+ const [creating, setCreating] = useState<{ kind: "dir" | "file"; value: string } | null>(null);
+ const [ask, setAsk] = useState(null);
+ const [askAll, setAskAll] = useState(false);
+ const [progress, setProgress] = useState<{
+ name: string;
+ done: number;
+ total: number;
+ sent: number;
+ bytes: number;
+ } | null>(null);
+ const [lanes, setLanes] = useState([]);
+ const [clip, setClip] = useState(clipboard);
+ const [dropping, setDropping] = useState(false);
+ const [menu, setMenu] = useState<"new" | "upload" | "more" | null>(null);
+ const fileInput = useRef(null);
+ const folderInput = useRef(null);
+ const ctx = useContextMenu();
+
+ // A new caller target (another workspace's Files tab, the page's URL).
+ useEffect(() => {
+ setTarget(placeOf());
+ setPathState(initialPath);
+ setHistory({ back: [], fwd: [] });
+ setSelected(new Set());
+ setView("files");
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [laneId, initialHome, session, source, initialPath]);
+
+ useEffect(() => {
+ api.lanes
+ .list()
+ .then((r) => setLanes(r.lanes))
+ .catch(() => setLanes([]));
+ api.files
+ .homes()
+ .then((r) => setHomes(r.homes))
+ .catch(() => setHomes([]));
+ }, []);
+ // Another user's home (an admin's view) is read-only.
+ const ro = isHomePlace(target) && !!homes.find((h) => h.id === target.home)?.readOnly;
+
+ /** Go to `p` in workspace `tgt`, remembering where we were for Back. */
+ const go = (p: string, tgt: FsPlace = target) => {
+ const sameLane = samePlace(tgt, target);
+ if (sameLane && p === path && view === "files") return;
+ setHistory((h) => ({ back: sameLane ? [...h.back, path] : [], fwd: [] }));
+ if (!sameLane) setTarget(tgt);
+ setPathState(p);
+ setView("files");
+ setSelected(new Set());
+ setQuery(null);
+ onNavigate?.(tgt, p);
+ };
+ const back = () => {
+ const prev = history.back[history.back.length - 1];
+ if (prev === undefined) return;
+ setHistory((h) => ({ back: h.back.slice(0, -1), fwd: [path, ...h.fwd] }));
+ setPathState(prev);
+ setSelected(new Set());
+ onNavigate?.(target, prev);
+ };
+ const forward = () => {
+ const next = history.fwd[0];
+ if (next === undefined) return;
+ setHistory((h) => ({ back: [...h.back, path], fwd: h.fwd.slice(1) }));
+ setPathState(next);
+ setSelected(new Set());
+ onNavigate?.(target, next);
+ };
+
+ const load = useCallback(async () => {
+ setError(null);
+ setLoading(true);
+ try {
+ if (view === "trash") setTrash((await api.files.trash(target)).items);
+ else setListing(await api.files.list(target, path));
+ } catch (e) {
+ // A dashboard that does not know /fs yet (not restarted) answers 404.
+ setError(
+ (e as { status?: number }).status === 404 && !errCode(e) ? t("fx.unavailable") : errText(e)
+ );
+ if (view === "files" && errCode(e) === "ENOFILE" && path) setPathState("");
+ } finally {
+ setLoading(false);
+ }
+ }, [target, path, view, t]);
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ const changed = () => {
+ void load();
+ onChanged?.();
+ };
+
+ const entries = useMemo(() => {
+ const all = listing?.entries ?? [];
+ const q = query?.trim().toLowerCase();
+ return all.filter(
+ (e) => (showHidden || !e.name.startsWith(".")) && (!q || e.name.toLowerCase().includes(q))
+ );
+ }, [listing, showHidden, query]);
+ const selectedEntries = entries.filter((e) => selected.has(e.path));
+
+ /** A choice for taken names: Replace / Keep both / Skip (null = cancel). */
+ const askConflict = (names: string[], many: boolean) =>
+ new Promise<{ choice: FsConflict | null; all: boolean }>((resolve) => {
+ setAskAll(false);
+ setAsk({ names, many, resolve });
+ });
+ const answer = (choice: FsConflict | null) => {
+ ask?.resolve({ choice, all: askAll });
+ setAsk(null);
+ };
+
+ /** Run `op` once; on EEXISTS ask, then run it again with the choice. */
+ const withConflict = async (op: (c: FsConflict) => Promise): Promise => {
+ try {
+ return await op("fail");
+ } catch (e) {
+ if (errCode(e) !== "EEXISTS") throw e;
+ const { choice } = await askConflict((e as { names?: string[] }).names ?? [], false);
+ if (!choice) return null;
+ return op(choice);
+ }
+ };
+
+ const run = async (fn: () => Promise, done?: string) => {
+ setBusy(true);
+ try {
+ await fn();
+ if (done) toast.success(done);
+ } catch (e) {
+ toast.error(errCode(e) === "EUPGRADE" ? t("fx.needsUpgrade") : errText(e));
+ } finally {
+ setBusy(false);
+ changed();
+ }
+ };
+
+ // ── Actions ─────────────────────────────────────────────────────────────
+ const openEntry = (e: FsEntry) => {
+ if (isFolder(e)) go(e.path);
+ else download([e]);
+ };
+ const goUp = () => {
+ if (listing?.parent != null) go(listing.parent);
+ };
+ const download = (items: FsEntry[]) => {
+ if (!items.length) return;
+ const one = items.length === 1 && !isFolder(items[0]!) ? items[0]! : null;
+ const a = document.createElement("a");
+ a.href = one
+ ? api.files.downloadUrl(target, one.path)
+ : api.files.zipUrl(
+ target,
+ items.map((i) => i.path)
+ );
+ a.download = one ? one.name : "";
+ a.click();
+ };
+ const removeItems = async (items: FsEntry[]) => {
+ if (!items.length) return;
+ await run(
+ () =>
+ api.files.remove(
+ target,
+ items.map((i) => i.path)
+ ),
+ t("fx.trashed", { count: items.length })
+ );
+ setSelected(new Set());
+ };
+ const startRename = (e: FsEntry) => setRenaming({ path: e.path, value: e.name });
+ const commitRename = async () => {
+ if (!renaming) return;
+ const entry = entries.find((e) => e.path === renaming.path);
+ const name = renaming.value.trim();
+ setRenaming(null);
+ if (!entry || !name || name === entry.name) return;
+ await run(() => api.files.rename(target, entry.path, name));
+ };
+ const startCreate = (kind: "dir" | "file") => {
+ setMenu(null);
+ setCreating({ kind, value: kind === "dir" ? t("fx.untitledFolder") : t("fx.untitledFile") });
+ };
+ const commitCreate = async () => {
+ if (!creating) return;
+ const name = creating.value.trim();
+ const kind = creating.kind;
+ setCreating(null);
+ if (!name) return;
+ await run(() =>
+ kind === "dir"
+ ? api.files.mkdir(target, join(path, name))
+ : api.files.newFile(target, join(path, name))
+ );
+ };
+ const putClip = (items: FsEntry[], cut: boolean) => {
+ if (!items.length) return;
+ // From another user's home (read-only) a cut is a copy.
+ clipboard = { place: target, paths: items.map((i) => i.path), cut: cut && !ro };
+ setClip(clipboard);
+ toast.info(t(cut ? "fx.cutDone" : "fx.copiedDone", { count: items.length }));
+ };
+ const paste = async (into = path) => {
+ setMenu(null);
+ const c = clipboard;
+ if (!c || ro) return;
+ const sameLane = samePlace(c.place, target);
+ if (!sameLane && (!wholePlace(c.place) || (isHomePlace(c.place) && isHomePlace(target)))) {
+ toast.error(t("fx.crossOnlyWorkspace"));
+ return;
+ }
+ await run(async () => {
+ if (sameLane && c.cut) {
+ await withConflict((conflict) => api.files.move(target, c.paths, into, conflict));
+ } else {
+ const done = await withConflict((conflict) =>
+ api.files.copy(target, c.paths, into, {
+ ...(sameLane
+ ? {}
+ : isHomePlace(c.place)
+ ? { fromHome: c.place.home }
+ : { fromLane: laneOf(c.place)! }),
+ conflict,
+ })
+ );
+ // A cut into another workspace: copied, then the originals trashed.
+ if (done && c.cut) await api.files.remove(c.place, c.paths);
+ }
+ if (c.cut) {
+ clipboard = null;
+ setClip(null);
+ }
+ }, t("fx.pasted"));
+ };
+ const moveInto = async (paths: string[], dest: string, copy: boolean) => {
+ const ok = paths.filter((p) => p !== dest && !dest.startsWith(`${p}/`));
+ if (!ok.length) return;
+ await run(() =>
+ withConflict((conflict) =>
+ copy
+ ? api.files.copy(target, ok, dest, { conflict })
+ : api.files.move(target, ok, dest, conflict)
+ )
+ );
+ };
+
+ /** Upload `files` (each with its path inside the pick/drop) under `dir`. */
+ const uploadAll = async (files: { file: File; rel: string }[], dir = path) => {
+ setMenu(null);
+ if (!files.length) return;
+ const bytes = files.reduce((n, f) => n + f.file.size, 0);
+ if (bytes > FX_BATCH_MAX_BYTES) {
+ toast.error(
+ t("fx.tooBig", { size: formatBytes(bytes), max: formatBytes(FX_BATCH_MAX_BYTES) })
+ );
+ return;
+ }
+ setBusy(true);
+ let sent = 0;
+ let saved = 0;
+ let forAll: FsConflict | null = null;
+ try {
+ for (let i = 0; i < files.length; i++) {
+ const { file, rel } = files[i]!;
+ const dest = join(dir, rel);
+ setProgress({ name: rel, done: i, total: files.length, sent, bytes });
+ const send = (c: FsConflict) =>
+ api.files.upload(target, dest, file, c, (n) =>
+ setProgress({ name: rel, done: i, total: files.length, sent: sent + n, bytes })
+ );
+ try {
+ const r = await send(forAll ?? "fail").catch(async (e) => {
+ if (errCode(e) !== "EEXISTS") throw e;
+ const a = await askConflict([rel], files.length > 1);
+ if (!a.choice) throw Object.assign(new Error("cancelled"), { code: "ECANCEL" });
+ if (a.all) forAll = a.choice;
+ return send(a.choice);
+ });
+ if (!r.skipped) saved++;
+ } catch (e) {
+ if (errCode(e) === "ECANCEL") break;
+ toast.error(t("fx.uploadFailed", { name: rel, message: errText(e) }));
+ }
+ sent += file.size;
+ }
+ } finally {
+ setProgress(null);
+ setBusy(false);
+ if (saved) toast.success(t("fx.uploaded", { count: saved }));
+ changed();
+ }
+ };
+
+ const onDrop = async (e: ReactDragEvent, dir = path) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setDropping(false);
+ if (ro) {
+ toast.error(t("fx.readOnlyHint"));
+ return;
+ }
+ const inner = e.dataTransfer.getData(MIME);
+ if (inner) {
+ await moveInto(JSON.parse(inner) as string[], dir, e.ctrlKey || e.metaKey);
+ return;
+ }
+ const out: { file: File; rel: string }[] = [];
+ const dropped = [...e.dataTransfer.items]
+ .map((i) => i.webkitGetAsEntry?.())
+ .filter(Boolean) as FileSystemEntry[];
+ if (dropped.length) {
+ for (const en of dropped) await walkEntry(en, "", out);
+ } else {
+ for (const f of e.dataTransfer.files) out.push({ file: f, rel: f.name });
+ }
+ await uploadAll(out, dir);
+ };
+
+ // ── Selection & keys ────────────────────────────────────────────────────
+ const click = (e: ReactMouseEvent, entry: FsEntry) => {
+ e.stopPropagation();
+ const next = new Set(selected);
+ if (e.shiftKey && anchor.current) {
+ const a = entries.findIndex((x) => x.path === anchor.current);
+ const b = entries.findIndex((x) => x.path === entry.path);
+ const [lo, hi] = a < b ? [a, b] : [b, a];
+ for (const x of entries.slice(lo, hi + 1)) next.add(x.path);
+ } else if (e.ctrlKey || e.metaKey) {
+ if (next.has(entry.path)) next.delete(entry.path);
+ else next.add(entry.path);
+ anchor.current = entry.path;
+ } else {
+ next.clear();
+ next.add(entry.path);
+ anchor.current = entry.path;
+ }
+ setSelected(next);
+ };
+ const toggleHidden = () => {
+ setShowHidden((v) => {
+ writePref(HIDDEN_KEY, v ? "0" : "1");
+ return !v;
+ });
+ setMenu(null);
+ };
+ const setLayoutPref = (l: "grid" | "list") => {
+ setLayout(l);
+ writePref(VIEW_KEY, l);
+ };
+ const onKey = (e: ReactKeyboardEvent) => {
+ if (renaming || creating || ask) return;
+ const mod = e.ctrlKey || e.metaKey;
+ const k = e.key.toLowerCase();
+ // Another user's home is read-only: no key that writes.
+ if (ro && (e.key === "Delete" || e.key === "F2" || (mod && (k === "x" || k === "v")))) return;
+ if (e.altKey && e.key === "ArrowLeft") back();
+ else if (e.altKey && e.key === "ArrowRight") forward();
+ else if (e.altKey && e.key === "ArrowUp") goUp();
+ else if (mod && k === "h") toggleHidden();
+ else if (view !== "files") return;
+ else if (e.key === "Delete") void removeItems(selectedEntries);
+ else if (e.key === "F2" && selectedEntries.length === 1) startRename(selectedEntries[0]!);
+ else if (e.key === "Enter" && selectedEntries.length === 1) openEntry(selectedEntries[0]!);
+ else if (e.key === "Backspace") goUp();
+ else if (mod && k === "a") setSelected(new Set(entries.map((x) => x.path)));
+ else if (mod && k === "c") putClip(selectedEntries, false);
+ else if (mod && k === "x") putClip(selectedEntries, true);
+ else if (mod && k === "v") void paste();
+ else if (mod && k === "f") setQuery((q) => q ?? "");
+ else return;
+ e.preventDefault();
+ e.stopPropagation();
+ };
+
+ const homeName = (who: string) =>
+ who === "me" ? t("fx.myHome") : (homes.find((h) => h.id === who)?.label ?? who);
+ const placeName = (p: FsPlace) => (isHomePlace(p) ? homeName(p.home) : laneName(laneOf(p)!));
+ const laneName = (id: number) => {
+ const l = lanes.find((x) => x.id === id);
+ return l ? l.title || l.cwd.split("/").pop() || `#${id}` : `#${id}`;
+ };
+ const crumbs = path ? path.split("/") : [];
+ const menuItem = ctx.menu?.item ?? null;
+ const menuTargets = menuItem ? (selected.has(menuItem.path) ? selectedEntries : [menuItem]) : [];
+ const close = (fn: () => void) => () => {
+ ctx.close();
+ fn();
+ };
+ const itemProps = (e: FsEntry) => ({
+ draggable: !renaming,
+ onDragStart: (ev: ReactDragEvent) => {
+ const paths = selected.has(e.path) ? [...selected] : [e.path];
+ ev.dataTransfer.setData(MIME, JSON.stringify(paths));
+ // Into a terminal or the prompt: the path as text.
+ ev.dataTransfer.setData("text/plain", e.path);
+ ev.dataTransfer.effectAllowed = "copyMove";
+ },
+ onDragOver: isFolder(e) ? (ev: ReactDragEvent) => ev.preventDefault() : undefined,
+ onDrop: isFolder(e) ? (ev: ReactDragEvent) => void onDrop(ev, e.path) : undefined,
+ onClick: (ev: ReactMouseEvent) => click(ev, e),
+ onDoubleClick: () => openEntry(e),
+ onContextMenu: (ev: ReactMouseEvent) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+ if (!selected.has(e.path)) setSelected(new Set([e.path]));
+ ctx.open(ev, e);
+ },
+ });
+ const nameBox = (e: FsEntry, center: boolean) =>
+ renaming?.path === e.path ? (
+ setRenaming({ ...renaming, value: v })}
+ onDone={() => void commitRename()}
+ onCancel={() => setRenaming(null)}
+ />
+ ) : null;
+ const placeClass = (active: boolean) =>
+ `flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] ${
+ active ? "bg-accent/15 font-medium text-accent" : "text-fg-secondary hover:bg-surface-2"
+ }`;
+
+ return (
+ setMenu(null)}
+ data-testid="file-explorer"
+ >
+ {/* ── Places ── */}
+
+
+ {homes.some((h) => h.id === "me") && (
+
+ go("", { home: "me" })}
+ className={placeClass(
+ view === "files" && isHomePlace(target) && target.home === "me"
+ )}
+ >
+
+ {t("fx.myHome")}
+
+
+ )}
+ {homes.some((h) => h.id !== "me") && (
+ <>
+
+ {t("fx.userHomes")}
+
+ {homes
+ .filter((h) => h.id !== "me")
+ .map((h) => (
+ go("", { home: h.id })}
+ className={placeClass(
+ view === "files" && isHomePlace(target) && target.home === h.id
+ )}
+ >
+
+ {h.label}
+
+
+ ))}
+ >
+ )}
+
+ {t("fx.places")}
+
+ {lanes.map((l) => (
+ go("", l.id)}
+ className={placeClass(
+ view === "files" && !isHomePlace(target) && laneOf(target) === l.id
+ )}
+ >
+
+ {l.title || l.cwd.split("/").pop()}
+
+ ))}
+
+
+ {
+ setView("trash");
+ setSelected(new Set());
+ }}
+ className={placeClass(view === "trash")}
+ >
+
+ {t("fx.trash")}
+
+
+
+
+
+ {/* ── Header bar ── */}
+
+
+
+
+
+
+
+
+
+
+
+ {/* Path bar — or the search field in its place, like GNOME Files. */}
+ {query !== null ? (
+
+
+ setQuery(e.target.value)}
+ onKeyDown={(e) => {
+ e.stopPropagation();
+ if (e.key === "Escape") setQuery(null);
+ }}
+ placeholder={t("fx.searchHere")}
+ aria-label={t("fx.searchHere")}
+ className="min-w-0 flex-1 bg-transparent text-sm outline-none"
+ />
+ setQuery(null)}>
+
+
+
+ ) : (
+
+ {view === "trash" ? (
+
+ {t("fx.trash")}
+
+ ) : (
+ <>
+ go("")}
+ onDragOver={(e) => e.preventDefault()}
+ onDrop={(e) => void onDrop(e, "")}
+ className={`flex shrink-0 items-center gap-1.5 rounded px-2 py-1 text-sm hover:bg-surface-3 ${
+ crumbs.length ? "text-fg-secondary" : "font-semibold"
+ }`}
+ >
+ {isHomePlace(target) ? (
+
+ ) : (
+
+ )}
+ {placeName(target)}
+
+ {crumbs.map((c, i) => (
+
+
+ go(crumbs.slice(0, i + 1).join("/"))}
+ onDragOver={(e) => e.preventDefault()}
+ onDrop={(e) => void onDrop(e, crumbs.slice(0, i + 1).join("/"))}
+ className={`rounded px-2 py-1 text-sm hover:bg-surface-3 ${
+ i === crumbs.length - 1 ? "font-semibold" : "text-fg-secondary"
+ }`}
+ >
+ {c}
+
+
+ ))}
+ >
+ )}
+
+ )}
+
+ {view === "files" && (
+ <>
+ setQuery((q) => (q === null ? "" : null))}
+ >
+
+
+
+ setLayoutPref("grid")}
+ >
+
+
+ setLayoutPref("list")}
+ >
+
+
+
+ {ro ? (
+
+
+ {t("fx.readOnly")}
+
+ ) : (
+ <>
+ }
+ open={menu === "new"}
+ onToggle={() => setMenu(menu === "new" ? null : "new")}
+ disabled={busy}
+ >
+ }
+ onClick={() => startCreate("dir")}
+ >
+ {t("fx.newFolder")}
+
+ }
+ onClick={() => startCreate("file")}
+ >
+ {t("fx.newFile")}
+
+
+ }
+ primary
+ open={menu === "upload"}
+ onToggle={() => setMenu(menu === "upload" ? null : "upload")}
+ disabled={busy}
+ >
+ }
+ onClick={() => fileInput.current?.click()}
+ >
+ {t("fx.uploadFiles")}
+
+ }
+ onClick={() => folderInput.current?.click()}
+ >
+ {t("fx.uploadFolder")}
+
+
+ >
+ )}
+ }
+ open={menu === "more"}
+ onToggle={() => setMenu(menu === "more" ? null : "more")}
+ >
+ void paste()} disabled={!clip || ro}>
+ {clip
+ ? t("fx.pasteFrom", {
+ count: clip.paths.length,
+ workspace: placeName(clip.place),
+ })
+ : t("fx.paste")}
+
+ {
+ setMenu(null);
+ setSelected(new Set(entries.map((x) => x.path)));
+ }}
+ >
+ {t("fx.selectAll")}
+
+
+ {showHidden ? "✓ " : ""}
+ {t("fx.showHidden")}
+
+ {
+ setMenu(null);
+ void load();
+ }}
+ >
+ {t("fx.refresh")}
+
+
+ >
+ )}
+ {onClose && (
+
+
+
+ )}
+ {
+ const files = [...(e.target.files ?? [])].map((f) => ({ file: f, rel: f.name }));
+ e.target.value = "";
+ void uploadAll(files);
+ }}
+ />
+ )}
+ onChange={(e) => {
+ const files = [...(e.target.files ?? [])].map((f) => ({
+ file: f,
+ rel: f.webkitRelativePath || f.name,
+ }));
+ e.target.value = "";
+ void uploadAll(files);
+ }}
+ />
+
+
+ {view === "files" ? (
+
{
+ e.preventDefault();
+ setSelected(new Set());
+ ctx.open(e, null);
+ }}
+ onDragOver={(e) => {
+ e.preventDefault();
+ if (!e.dataTransfer.types.includes(MIME)) setDropping(true);
+ }}
+ onDragLeave={(e) => {
+ if (e.currentTarget === e.target) setDropping(false);
+ }}
+ onDrop={(e) => void onDrop(e)}
+ onClick={() => setSelected(new Set())}
+ className="relative min-h-0 flex-1 overflow-y-auto outline-none"
+ >
+ {error && (
+
+ {error}
+
+ )}
+ {layout === "grid" ? (
+
+ {creating && (
+
+ {creating.kind === "dir" ? (
+
+ ) : (
+
+ )}
+ setCreating({ ...creating, value: v })}
+ onDone={() => void commitCreate()}
+ onCancel={() => setCreating(null)}
+ />
+
+ )}
+ {entries.map((e) => (
+
+ {e.type === "file" &&
+ !isHomePlace(target) &&
+ IMAGE.has(ext(e.name)) &&
+ (e.size ?? 0) < 5 * 1024 * 1024 ? (
+
,
+ e.path
+ )}
+ alt=""
+ loading="lazy"
+ draggable={false}
+ className="h-12 w-12 rounded object-cover"
+ />
+ ) : (
+
+ )}
+ {nameBox(e, true) ?? (
+
+ {e.name}
+
+ )}
+
+ ))}
+
+ ) : (
+
+
+
+ {t("fx.name")}
+ {t("fx.size")}
+ {t("fx.modified")}
+
+
+
+ {creating && (
+
+
+ setCreating({ ...creating, value: v })}
+ onDone={() => void commitCreate()}
+ onCancel={() => setCreating(null)}
+ />
+
+
+ )}
+ {entries.map((e) => (
+
+
+
+
+ {nameBox(e, false) ?? (
+
+ {e.name}
+
+ )}
+
+
+
+ {e.size != null ? formatBytes(e.size) : isFolder(e) ? "—" : ""}
+
+
+ {new Date(e.mtime).toLocaleString()}
+
+
+ ))}
+
+
+ )}
+ {listing && !entries.length && !creating && !error && (
+
+
+
{query ? t("fx.noMatch") : t("fx.empty")}
+
+ )}
+ {loading && !listing &&
{t("fx.loading")}
}
+ {dropping && (
+
+
+ {t("fx.dropHere", {
+ folder: crumbs[crumbs.length - 1] ?? placeName(target),
+ })}
+
+
+ )}
+
+ ) : (
+
void run(() => api.files.restore(target, id), t("fx.restored"))}
+ onPurge={(id) => void run(() => api.files.purge(target, id))}
+ />
+ )}
+
+ {/* ── Selection action bar / status bar ── */}
+ {view === "files" && selected.size > 0 ? (
+
+
+ {t("fx.selectedCount", { count: selected.size })}
+
+
}
+ onClick={() => download(selectedEntries)}
+ >
+ {t("fx.download")}
+
+
}
+ onClick={() => putClip(selectedEntries, false)}
+ >
+ {t("fx.copy")}
+
+ {!ro && (
+
}
+ onClick={() => putClip(selectedEntries, true)}
+ >
+ {t("fx.cut")}
+
+ )}
+ {!ro && selectedEntries.length === 1 && (
+
}
+ onClick={() => startRename(selectedEntries[0]!)}
+ >
+ {t("fx.rename")}
+
+ )}
+ {!ro && (
+
}
+ disabled={busy}
+ onClick={() => void removeItems(selectedEntries)}
+ >
+ {t("fx.delete")}
+
+ )}
+
setSelected(new Set())}
+ >
+ {t("fx.clearSelection")}
+
+
+ ) : (
+
+
+ {view === "files" ? t("fx.itemsCount", { count: entries.length }) : t("fx.trashHint")}
+
+ {clip && view === "files" && (
+ void paste()}
+ >
+
+ {t("fx.pasteFrom", { count: clip.paths.length, workspace: placeName(clip.place) })}
+
+ )}
+ {t("fx.dropHint")}
+
+ )}
+
+ {progress && (
+
+
+ {t("fx.uploading", { done: progress.done + 1, total: progress.total })}
+
+
{progress.name}
+
+
+ )}
+
+
+ {ctx.menu && (
+
+ {menuItem ? (
+ <>
+ openEntry(menuItem))}>
+ {isFolder(menuItem) ? t("fx.open") : t("fx.download")}
+
+ {(isFolder(menuItem) || menuTargets.length > 1) && (
+ download(menuTargets))}>
+ {t("fx.downloadZip")}
+
+ )}
+
+ {!ro && (
+ putClip(menuTargets, true))}>
+ {t("fx.cut")}
+
+ )}
+ putClip(menuTargets, false))}>
+ {t("fx.copy")}
+
+ {!ro && isFolder(menuItem) && clip && (
+ void paste(menuItem.path))}>
+ {t("fx.pasteInto")}
+
+ )}
+ {!ro && menuTargets.length === 1 && (
+ startRename(menuItem))}>{t("fx.rename")}
+ )}
+ void copyText(menuItem.path))}>
+ {t("fx.copyPath")}
+
+ {!ro && (
+ <>
+
+ void removeItems(menuTargets))}>
+ {t("fx.delete")}
+
+ >
+ )}
+ >
+ ) : (
+ <>
+ {!ro && (
+ <>
+ startCreate("dir"))}>
+ {t("fx.newFolder")}
+
+ startCreate("file"))}>{t("fx.newFile")}
+
+ fileInput.current?.click())}>
+ {t("fx.uploadFiles")}
+
+ folderInput.current?.click())}>
+ {t("fx.uploadFolder")}
+
+ >
+ )}
+ {!ro && clip && (
+ <>
+
+ void paste())}>{t("fx.paste")}
+ >
+ )}
+
+ setSelected(new Set(entries.map((x) => x.path))))}>
+ {t("fx.selectAll")}
+
+
+ {showHidden ? "✓ " : ""}
+ {t("fx.showHidden")}
+
+ >
+ )}
+
+ )}
+
+ {ask && (
+
+
+
{t("fx.conflictTitle")}
+
+ {t("fx.conflictBody", { names: ask.names.slice(0, 5).join(", ") })}
+
+ {ask.many && (
+
+ setAskAll(e.target.checked)}
+ />
+ {t("fx.conflictAll")}
+
+ )}
+
+ answer(null)}
+ >
+ {t("fx.cancel")}
+
+ answer("skip")}
+ >
+ {t("fx.skip")}
+
+ answer("keep")}
+ >
+ {t("fx.keepBoth")}
+
+ answer("replace")}
+ >
+ {t("fx.replace")}
+
+
+
+
+ )}
+
+ );
+}
+
+/**
+ * The file manager in a large dialog (a workspace's Files tab opens it).
+ * Portalled to `document.body`: opened from inside the narrow Changes panel,
+ * whose ancestors make their own stacking context (and may be a containing
+ * block for `position: fixed`), it was boxed into that panel and drawn
+ * clipped over the file tree.
+ */
+export function FileExplorer({
+ open,
+ onClose,
+ ...rest
+}: {
+ open: boolean;
+ onClose: () => void;
+ laneId: number;
+ session?: string | null;
+ source?: number | null;
+ initialPath?: string;
+ onChanged?: () => void;
+}) {
+ const { t } = useTranslation(["lanes"]);
+ const panelRef = useRef(null);
+ const titleId = useId();
+ useDialog(open, panelRef, onClose);
+ if (!open) return null;
+ return createPortal(
+
+
e.stopPropagation()}
+ >
+
+ {t("fx.title")}
+
+
+
+
,
+ document.body
+ );
+}
+
+function HeadButton({
+ label,
+ onClick,
+ disabled,
+ pressed,
+ children,
+}: {
+ label: string;
+ onClick: () => void;
+ disabled?: boolean;
+ pressed?: boolean;
+ children: ReactNode;
+}) {
+ return (
+ {
+ e.stopPropagation();
+ onClick();
+ }}
+ className={`rounded-md p-1.5 transition-colors disabled:cursor-not-allowed disabled:opacity-35 ${
+ pressed
+ ? "bg-surface-3 text-fg-primary"
+ : "text-fg-secondary hover:bg-surface-2 hover:text-fg-primary"
+ }`}
+ >
+ {children}
+
+ );
+}
+
+function DropButton({
+ label,
+ icon,
+ open,
+ onToggle,
+ disabled,
+ primary,
+ iconOnly,
+ children,
+}: {
+ label: string;
+ icon: ReactNode;
+ open: boolean;
+ onToggle: () => void;
+ disabled?: boolean;
+ primary?: boolean;
+ iconOnly?: boolean;
+ children: ReactNode;
+}) {
+ return (
+ e.stopPropagation()}>
+
+ {icon}
+ {!iconOnly && (
+ <>
+ {label}
+
+ >
+ )}
+
+ {open && (
+
+ {children}
+
+ )}
+
+ );
+}
+
+function DropItem({
+ icon,
+ onClick,
+ disabled,
+ children,
+}: {
+ icon?: ReactNode;
+ onClick: () => void;
+ disabled?: boolean;
+ children: ReactNode;
+}) {
+ return (
+
+ {icon}
+ {children}
+
+ );
+}
+
+function ActionButton({
+ icon,
+ onClick,
+ disabled,
+ danger,
+ children,
+}: {
+ icon: ReactNode;
+ onClick: () => void;
+ disabled?: boolean;
+ danger?: boolean;
+ children: ReactNode;
+}) {
+ return (
+
+ {icon}
+ {children}
+
+ );
+}
+
+function InlineName({
+ value,
+ center,
+ onChange,
+ onDone,
+ onCancel,
+}: {
+ value: string;
+ center?: boolean;
+ onChange: (v: string) => void;
+ onDone: () => void;
+ onCancel: () => void;
+}) {
+ const { t } = useTranslation(["lanes"]);
+ const ref = useRef(null);
+ useEffect(() => {
+ const el = ref.current;
+ if (!el) return;
+ el.focus();
+ // Select the stem, like a desktop rename: "name" of "name.ext".
+ const dot = value.lastIndexOf(".");
+ el.setSelectionRange(0, dot > 0 ? dot : value.length);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+ return (
+ onChange(e.target.value)}
+ onClick={(e) => e.stopPropagation()}
+ onDoubleClick={(e) => e.stopPropagation()}
+ onKeyDown={(e) => {
+ e.stopPropagation();
+ if (e.key === "Enter") onDone();
+ if (e.key === "Escape") onCancel();
+ }}
+ onBlur={onDone}
+ className={`w-full rounded border border-accent bg-surface-0 px-1.5 py-0.5 text-xs outline-none ${
+ center ? "text-center" : ""
+ }`}
+ />
+ );
+}
+
+function TrashView({
+ items,
+ laneName,
+ onRestore,
+ onPurge,
+}: {
+ items: FsTrashItem[];
+ laneName: (id: number) => string;
+ onRestore: (id: string) => void;
+ onPurge: (id: string) => void;
+}) {
+ const { t } = useTranslation(["lanes"]);
+ return (
+
+
+ {t("fx.trashHint")}
+ onPurge("all")}
+ className="btn-ghost border border-border text-xs hover:text-status-danger disabled:opacity-40"
+ >
+ {t("fx.emptyTrash")}
+
+
+
+
+
+
+ {t("fx.name")}
+ {t("fx.from")}
+ {t("fx.deletedAt")}
+
+
+
+
+ {items.map((i) => (
+
+
+
+ {i.isDir ? (
+
+ ) : (
+
+ )}
+ {i.path}
+
+
+
+ {i.laneId != null ? laneName(i.laneId) : ""}
+
+
+ {new Date(i.deletedAt).toLocaleString()}
+
+
+
+ onRestore(i.id)}
+ >
+
+ {t("fx.restore")}
+
+ onPurge(i.id)}
+ >
+ {t("fx.purge")}
+
+
+
+
+ ))}
+
+
+ {!items.length && (
+
+
+
{t("fx.trashEmpty")}
+
+ )}
+
+
+ );
+}
diff --git a/client/src/components/lanes/LaneChangesPanel.tsx b/client/src/components/lanes/LaneChangesPanel.tsx
index 0cb91f72..58677a89 100644
--- a/client/src/components/lanes/LaneChangesPanel.tsx
+++ b/client/src/components/lanes/LaneChangesPanel.tsx
@@ -102,7 +102,7 @@ import {
Sparkles,
Trash2,
UnfoldVertical,
- Upload,
+ FolderOpen,
} from "lucide-react";
import { api, gitTarget } from "../../lib/api";
import { pasteToLane } from "../../lib/panePaste";
@@ -123,6 +123,7 @@ import {
type LaneDiffChange,
} from "./LaneDiffView";
import { LaneConflictView } from "./LaneConflictView";
+import { FileExplorer } from "./FileExplorer";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
@@ -1639,6 +1640,14 @@ export function LaneChangesPanel({
{t("changes.uploadHere")}
)}
+ {treeCtx.menu.item.dir && (
+ setExplorer({ path: treeCtx.menu!.item.path }))}
+ >
+ {t("changes.openInExplorer")}
+
+ )}
copy(treeCtx.menu!.item.path))}>
{t("changes.copyPath")}
@@ -1888,7 +1897,11 @@ export function LaneChangesPanel({
// upload only creates, into the folder it was asked for ("" = the root).
const uploadInput = useRef(null);
const uploadDir = useRef("");
- const [uploading, setUploading] = useState(false);
+ const [, setUploading] = useState(false);
+ // The File Explorer (the toolbar button where Upload was); the tree is
+ // re-read when it closes after changing anything.
+ const [explorer, setExplorer] = useState<{ path: string } | null>(null);
+ const explorerChanged = useRef(false);
const download = (path: string) => {
const a = document.createElement("a");
a.href = api.lanes.gitDownloadUrl(target, path);
@@ -2121,9 +2134,24 @@ export function LaneChangesPanel({
aria-label={t("changes.quickOpen")}
className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
/>
- pickUpload("")}>
-
+ setExplorer({ path: "" })}>
+
+ (explorerChanged.current = true)}
+ onClose={() => {
+ setExplorer(null);
+ if (explorerChanged.current) {
+ explorerChanged.current = false;
+ void rereadAll();
+ }
+ }}
+ />
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { FileExplorer } from "../FileExplorer";
+import { api, type FsListing } from "../../../lib/api";
+import { toast } from "../../../lib/toast";
+
+vi.mock("../../../lib/api", () => ({
+ gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
+ isHomePlace: (p: unknown) => typeof p === "object" && p !== null && "home" in p,
+ api: {
+ lanes: {
+ list: vi.fn(),
+ gitDownloadUrl: (t: number, p: string) => `/api/lanes/${t}/git/download?path=${p}`,
+ },
+ files: {
+ homes: vi.fn(),
+ downloadUrl: (t: unknown, p: string) => `/dl/${JSON.stringify(t)}/${p}`,
+ list: vi.fn(),
+ mkdir: vi.fn(),
+ newFile: vi.fn(),
+ rename: vi.fn(),
+ move: vi.fn(),
+ copy: vi.fn(),
+ remove: vi.fn(),
+ trash: vi.fn(),
+ restore: vi.fn(),
+ purge: vi.fn(),
+ upload: vi.fn(),
+ zipUrl: (t: number, ps: string[]) => `/api/lanes/${t}/fs/zip?p=${ps.join("&p=")}`,
+ },
+ },
+}));
+vi.mock("../../../lib/toast", () => ({
+ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
+}));
+
+const root: FsListing = {
+ path: "",
+ parent: null,
+ truncated: false,
+ entries: [
+ { name: "src", path: "src", type: "dir", size: null, mtime: 0 },
+ { name: "a.txt", path: "a.txt", type: "file", size: 12, mtime: 0 },
+ ],
+};
+const src: FsListing = {
+ path: "src",
+ parent: "",
+ truncated: false,
+ entries: [{ name: "x.js", path: "src/x.js", type: "file", size: 2, mtime: 0 }],
+};
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(api.lanes.list).mockResolvedValue({
+ lanes: [
+ { id: 1, title: "one", cwd: "/w/one" },
+ { id: 2, title: "two", cwd: "/w/two" },
+ ],
+ counts: {},
+ } as never);
+ vi.mocked(api.files.list).mockImplementation(async (_t, p = "") => (p === "src" ? src : root));
+ vi.mocked(api.files.trash).mockResolvedValue({ items: [] });
+ vi.mocked(api.files.homes).mockResolvedValue({
+ homes: [
+ { id: "me", label: "An", readOnly: false },
+ { id: "bob", label: "Bob", readOnly: true },
+ ],
+ });
+});
+
+const open = () => render( );
+const row = (name: string) =>
+ screen.getAllByTestId("fx-row").find((r) => r.textContent?.includes(name))!;
+
+describe("FileExplorer", () => {
+ it("hides dot files until Ctrl+H, like the Linux file manager", async () => {
+ vi.mocked(api.files.list).mockResolvedValue({
+ ...root,
+ entries: [
+ ...root.entries,
+ { name: ".env.example", path: ".env.example", type: "file", size: 1, mtime: 0 },
+ ],
+ });
+ open();
+ await screen.findByText("a.txt");
+ expect(screen.queryByText(".env.example")).toBeNull();
+ fireEvent.keyDown(screen.getByTestId("fx-list"), { key: "h", ctrlKey: true });
+ expect(await screen.findByText(".env.example")).toBeInTheDocument();
+ });
+
+ it("shows a labelled action bar for a selection, and goes Back after opening a folder", async () => {
+ const user = userEvent.setup();
+ open();
+ await screen.findByText("a.txt");
+ await user.click(row("a.txt"));
+ const bar = screen.getByTestId("fx-actionbar");
+ expect(within(bar).getByRole("button", { name: /Download/ })).toBeInTheDocument();
+ expect(within(bar).getByRole("button", { name: /Move to Trash/ })).toBeInTheDocument();
+ fireEvent.doubleClick(row("src"));
+ await screen.findByText("x.js");
+ await user.click(screen.getByRole("button", { name: "Back" }));
+ await waitFor(() => expect(api.files.list).toHaveBeenLastCalledWith(1, ""));
+ });
+
+ it("lists a folder and opens a sub-folder on double-click", async () => {
+ open();
+ expect(await screen.findByText("a.txt")).toBeInTheDocument();
+ fireEvent.doubleClick(row("src"));
+ expect(await screen.findByText("x.js")).toBeInTheDocument();
+ expect(api.files.list).toHaveBeenLastCalledWith(1, "src");
+ });
+
+ it("makes a folder inline", async () => {
+ vi.mocked(api.files.mkdir).mockResolvedValue({ path: "docs" });
+ const user = userEvent.setup();
+ open();
+ await screen.findByText("a.txt");
+ await user.click(screen.getByRole("button", { name: "New" }));
+ await user.click(screen.getByRole("menuitem", { name: "New folder" }));
+ const name = screen.getByLabelText("Name");
+ await user.clear(name);
+ await user.type(name, "docs{Enter}");
+ await waitFor(() => expect(api.files.mkdir).toHaveBeenCalledWith(1, "docs"));
+ });
+
+ it("deletes into the Trash without asking", async () => {
+ vi.mocked(api.files.remove).mockResolvedValue({ trashed: ["id1"] });
+ open();
+ await screen.findByText("a.txt");
+ fireEvent.click(row("a.txt"));
+ fireEvent.keyDown(screen.getByTestId("fx-list"), { key: "Delete" });
+ await waitFor(() => expect(api.files.remove).toHaveBeenCalledWith(1, ["a.txt"]));
+ expect(screen.queryByRole("alertdialog")).toBeNull();
+ });
+
+ it("copies here and pastes into another workspace", async () => {
+ vi.mocked(api.files.copy).mockResolvedValue({ copied: ["a.txt"] });
+ const user = userEvent.setup();
+ open();
+ await screen.findByText("a.txt");
+ fireEvent.click(row("a.txt"));
+ fireEvent.keyDown(screen.getByTestId("fx-list"), { key: "c", ctrlKey: true });
+ await user.click(await screen.findByRole("button", { name: "two" }));
+ await waitFor(() => expect(api.files.list).toHaveBeenLastCalledWith(2, ""));
+ await user.click(screen.getByRole("button", { name: /Paste 1 item/ }));
+ await waitFor(() =>
+ expect(api.files.copy).toHaveBeenCalledWith(2, ["a.txt"], "", {
+ fromLane: 1,
+ conflict: "fail",
+ })
+ );
+ });
+
+ it("asks about a taken name on upload, and sends the choice", async () => {
+ vi.mocked(api.files.upload)
+ .mockRejectedValueOnce(
+ Object.assign(new Error("taken"), { code: "EEXISTS", names: ["a.txt"] })
+ )
+ .mockResolvedValueOnce({ path: "a (1).txt" });
+ const user = userEvent.setup();
+ open();
+ await screen.findByText("a.txt");
+ const file = new File(["hi"], "a.txt");
+ await user.upload(screen.getByTestId("fx-upload-files"), file);
+ const ask = await screen.findByTestId("fx-conflict");
+ await user.click(within(ask).getByRole("button", { name: "Keep both" }));
+ await waitFor(() =>
+ expect(api.files.upload).toHaveBeenLastCalledWith(
+ 1,
+ "a.txt",
+ file,
+ "keep",
+ expect.any(Function)
+ )
+ );
+ });
+
+ it("refuses a batch over 500 MB before sending anything", async () => {
+ open();
+ await screen.findByText("a.txt");
+ const big = new File(["x"], "big.bin");
+ Object.defineProperty(big, "size", { value: 600 * 1024 * 1024 });
+ fireEvent.change(screen.getByTestId("fx-upload-files"), { target: { files: [big] } });
+ await waitFor(() => expect(toast.error).toHaveBeenCalled());
+ expect(api.files.upload).not.toHaveBeenCalled();
+ });
+
+ it("restores from the Trash", async () => {
+ vi.mocked(api.files.trash).mockResolvedValue({
+ items: [
+ {
+ id: "abc12345",
+ name: "a.txt",
+ laneId: 1,
+ path: "a.txt",
+ isDir: false,
+ deletedAt: "2026-10-14T00:00:00Z",
+ },
+ ],
+ });
+ vi.mocked(api.files.restore).mockResolvedValue({ path: "a.txt", laneId: null });
+ const user = userEvent.setup();
+ open();
+ await screen.findByText("a.txt");
+ await user.click(screen.getByRole("button", { name: "Trash" }));
+ const trashRow = await screen.findByTestId("fx-trash-row");
+ await user.click(within(trashRow).getByRole("button", { name: /Restore/ }));
+ await waitFor(() => expect(api.files.restore).toHaveBeenCalledWith(1, "abc12345"));
+ });
+
+ it("opens the user's home from the sidebar, and pastes from it into a workspace", async () => {
+ vi.mocked(api.files.copy).mockResolvedValue({ copied: ["a.txt"] });
+ const user = userEvent.setup();
+ open();
+ await screen.findByText("a.txt");
+ await user.click(await screen.findByRole("button", { name: "Home" }));
+ await waitFor(() => expect(api.files.list).toHaveBeenLastCalledWith({ home: "me" }, ""));
+ fireEvent.click(row("a.txt"));
+ fireEvent.keyDown(screen.getByTestId("fx-list"), { key: "c", ctrlKey: true });
+ await user.click(screen.getByRole("button", { name: "one" }));
+ await waitFor(() => expect(api.files.list).toHaveBeenLastCalledWith(1, ""));
+ await user.click(screen.getByRole("button", { name: /Paste 1 item/ }));
+ await waitFor(() =>
+ expect(api.files.copy).toHaveBeenCalledWith(1, ["a.txt"], "", {
+ fromHome: "me",
+ conflict: "fail",
+ })
+ );
+ });
+
+ it("shows another user's home read-only: no New, Upload, Cut, Rename or Delete", async () => {
+ const user = userEvent.setup();
+ open();
+ await screen.findByText("a.txt");
+ await user.click(await screen.findByRole("button", { name: /Bob/ }));
+ expect(await screen.findByTestId("fx-readonly")).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "New" })).toBeNull();
+ expect(screen.queryByRole("button", { name: "Upload" })).toBeNull();
+ await user.click(row("a.txt"));
+ const bar = screen.getByTestId("fx-actionbar");
+ expect(within(bar).getByRole("button", { name: /Download/ })).toBeInTheDocument();
+ expect(within(bar).getByRole("button", { name: /Copy/ })).toBeInTheDocument();
+ expect(within(bar).queryByRole("button", { name: /Cut|Rename|Move to Trash/ })).toBeNull();
+ fireEvent.keyDown(screen.getByTestId("fx-list"), { key: "Delete" });
+ expect(api.files.remove).not.toHaveBeenCalled();
+ });
+});
diff --git a/client/src/i18n/locales/en/lanes.json b/client/src/i18n/locales/en/lanes.json
index d1110afb..5226d79b 100644
--- a/client/src/i18n/locales/en/lanes.json
+++ b/client/src/i18n/locales/en/lanes.json
@@ -522,5 +522,89 @@
"changes.historyWholeBranch": "Whole branch",
"changes.annotate": "Annotate",
"changes.noBlame": "Nothing to annotate: this file has no committed history yet",
- "changes.notCommitted": "Not committed yet"
+ "changes.notCommitted": "Not committed yet",
+ "fx.title": "Files",
+ "fx.workspace": "Workspace",
+ "fx.files": "Files",
+ "fx.trash": "Trash",
+ "fx.close": "Close",
+ "fx.up": "Parent folder",
+ "fx.refresh": "Refresh",
+ "fx.newFolder": "New folder",
+ "fx.newFile": "New file",
+ "fx.uploadFiles": "Upload files",
+ "fx.uploadFolder": "Upload a folder",
+ "fx.download": "Download",
+ "fx.downloadZip": "Download as zip",
+ "fx.rename": "Rename",
+ "fx.copy": "Copy",
+ "fx.cut": "Cut",
+ "fx.paste": "Paste",
+ "fx.pasteInto": "Paste into this folder",
+ "fx.pasteFrom": "Paste {{count}} item(s) from {{workspace}}",
+ "fx.delete": "Move to Trash",
+ "fx.open": "Open",
+ "fx.location": "Location",
+ "fx.name": "Name",
+ "fx.size": "Size",
+ "fx.modified": "Modified",
+ "fx.empty": "This folder is empty — drop files here to upload.",
+ "fx.itemsCount": "{{count}} item(s)",
+ "fx.selectedCount": "{{count}} selected",
+ "fx.dropHint": "Drag files or folders here to upload · drag onto a folder to move (Ctrl: copy)",
+ "fx.uploading": "Uploading {{done}}/{{total}}…",
+ "fx.uploaded": "Uploaded {{count}} file(s).",
+ "fx.uploadFailed": "Could not upload {{name}}: {{message}}",
+ "fx.tooBig": "{{size}} is more than one upload may carry ({{max}}). Upload fewer files at a time.",
+ "fx.trashed": "Moved {{count}} item(s) to the Trash.",
+ "fx.copiedDone": "Copied {{count}} item(s) — open a folder (or another workspace) and Paste.",
+ "fx.cutDone": "Cut {{count}} item(s) — open a folder (or another workspace) and Paste.",
+ "fx.pasted": "Pasted.",
+ "fx.crossOnlyWorkspace": "These items can only be pasted where they came from (a session worktree, an extra folder, or between two homes).",
+ "fx.needsUpgrade": "This needs the updated server helper — ask an admin to run `sudo agenthub server install-system` once.",
+ "fx.conflictTitle": "Already there",
+ "fx.conflictBody": "This name is taken in the destination: {{names}}",
+ "fx.conflictAll": "Do this for every remaining file",
+ "fx.cancel": "Cancel",
+ "fx.skip": "Skip",
+ "fx.keepBoth": "Keep both",
+ "fx.replace": "Replace",
+ "fx.trashHint": "Deleted files and folders, restorable for 7 days.",
+ "fx.emptyTrash": "Empty Trash",
+ "fx.restore": "Restore",
+ "fx.purge": "Delete forever",
+ "fx.restored": "Restored.",
+ "fx.trashEmpty": "The Trash is empty.",
+ "changes.openExplorer": "Open File Explorer — manage, upload and download files",
+ "changes.openInExplorer": "Open in File Explorer",
+ "fx.places": "Workspaces",
+ "fx.back": "Back",
+ "fx.forward": "Forward",
+ "fx.search": "Search (Ctrl+F)",
+ "fx.searchHere": "Search this folder…",
+ "fx.viewAs": "View as",
+ "fx.gridView": "Icons",
+ "fx.listView": "List",
+ "fx.new": "New",
+ "fx.upload": "Upload",
+ "fx.more": "More",
+ "fx.selectAll": "Select all",
+ "fx.showHidden": "Show hidden files (Ctrl+H)",
+ "fx.untitledFolder": "New folder",
+ "fx.untitledFile": "untitled.txt",
+ "fx.copyPath": "Copy path",
+ "fx.clearSelection": "Clear selection",
+ "fx.noMatch": "Nothing here matches.",
+ "fx.loading": "Loading…",
+ "fx.dropHere": "Drop to upload into {{folder}}",
+ "fx.nameField": "Name",
+ "fx.from": "From",
+ "fx.deletedAt": "Deleted",
+ "fx.unavailable": "The file manager is not available on this dashboard yet — it needs a restart after the update.",
+ "fx.noWorkspace": "There is no workspace yet. Add one first, then its files show here.",
+ "fx.myHome": "Home",
+ "fx.userHomes": "Users' homes",
+ "fx.readOnlyHome": "Another user's home — view and download only",
+ "fx.readOnly": "Read-only",
+ "fx.readOnlyHint": "This is another user's home: you can view, copy and download, not change it."
}
diff --git a/client/src/i18n/locales/en/nav.json b/client/src/i18n/locales/en/nav.json
index f6602ff6..e5838565 100644
--- a/client/src/i18n/locales/en/nav.json
+++ b/client/src/i18n/locales/en/nav.json
@@ -85,5 +85,6 @@
"closeMenu": "Close menu",
"sidebar": "Sidebar",
"now": "now",
- "users": "Users"
+ "users": "Users",
+ "files": "Files"
}
diff --git a/client/src/i18n/locales/vi/lanes.json b/client/src/i18n/locales/vi/lanes.json
index ff1f9925..79121b63 100644
--- a/client/src/i18n/locales/vi/lanes.json
+++ b/client/src/i18n/locales/vi/lanes.json
@@ -522,5 +522,89 @@
"changes.historyWholeBranch": "Cả nhánh",
"changes.annotate": "Annotate",
"changes.noBlame": "Không có gì để annotate: file này chưa có lịch sử commit",
- "changes.notCommitted": "Chưa commit"
+ "changes.notCommitted": "Chưa commit",
+ "fx.title": "Quản lý file",
+ "fx.workspace": "Workspace",
+ "fx.files": "File",
+ "fx.trash": "Thùng rác",
+ "fx.close": "Đóng",
+ "fx.up": "Thư mục cha",
+ "fx.refresh": "Làm mới",
+ "fx.newFolder": "Thư mục mới",
+ "fx.newFile": "File mới",
+ "fx.uploadFiles": "Tải file lên",
+ "fx.uploadFolder": "Tải cả thư mục lên",
+ "fx.download": "Tải về",
+ "fx.downloadZip": "Tải về dạng zip",
+ "fx.rename": "Đổi tên",
+ "fx.copy": "Sao chép",
+ "fx.cut": "Cắt",
+ "fx.paste": "Dán",
+ "fx.pasteInto": "Dán vào thư mục này",
+ "fx.pasteFrom": "Dán {{count}} mục từ {{workspace}}",
+ "fx.delete": "Chuyển vào Thùng rác",
+ "fx.open": "Mở",
+ "fx.location": "Vị trí",
+ "fx.name": "Tên",
+ "fx.size": "Dung lượng",
+ "fx.modified": "Sửa lần cuối",
+ "fx.empty": "Thư mục trống — thả file vào đây để tải lên.",
+ "fx.itemsCount": "{{count}} mục",
+ "fx.selectedCount": "Đã chọn {{count}}",
+ "fx.dropHint": "Kéo file hoặc thư mục vào đây để tải lên · kéo vào một thư mục để di chuyển (Ctrl: sao chép)",
+ "fx.uploading": "Đang tải lên {{done}}/{{total}}…",
+ "fx.uploaded": "Đã tải lên {{count}} file.",
+ "fx.uploadFailed": "Không tải lên được {{name}}: {{message}}",
+ "fx.tooBig": "{{size}} vượt quá giới hạn một lần tải lên ({{max}}). Hãy tải ít file hơn mỗi lần.",
+ "fx.trashed": "Đã chuyển {{count}} mục vào Thùng rác.",
+ "fx.copiedDone": "Đã sao chép {{count}} mục — mở thư mục (hoặc workspace khác) rồi Dán.",
+ "fx.cutDone": "Đã cắt {{count}} mục — mở thư mục (hoặc workspace khác) rồi Dán.",
+ "fx.pasted": "Đã dán.",
+ "fx.crossOnlyWorkspace": "Các mục này chỉ dán được ở nơi chúng được lấy ra (worktree của session, thư mục phụ, hoặc giữa hai home).",
+ "fx.needsUpgrade": "Cần cập nhật helper của server — nhờ admin chạy `sudo agenthub server install-system` một lần.",
+ "fx.conflictTitle": "Đã có sẵn",
+ "fx.conflictBody": "Tên này đã có ở nơi đến: {{names}}",
+ "fx.conflictAll": "Áp dụng cho tất cả file còn lại",
+ "fx.cancel": "Huỷ",
+ "fx.skip": "Bỏ qua",
+ "fx.keepBoth": "Giữ cả hai",
+ "fx.replace": "Thay thế",
+ "fx.trashHint": "File và thư mục đã xoá, khôi phục được trong 7 ngày.",
+ "fx.emptyTrash": "Dọn sạch Thùng rác",
+ "fx.restore": "Khôi phục",
+ "fx.purge": "Xoá vĩnh viễn",
+ "fx.restored": "Đã khôi phục.",
+ "fx.trashEmpty": "Thùng rác trống.",
+ "changes.openExplorer": "Mở Quản lý file — quản lý, tải lên và tải về file",
+ "changes.openInExplorer": "Mở trong Quản lý file",
+ "fx.places": "Workspace",
+ "fx.back": "Quay lại",
+ "fx.forward": "Tiến tới",
+ "fx.search": "Tìm (Ctrl+F)",
+ "fx.searchHere": "Tìm trong thư mục này…",
+ "fx.viewAs": "Kiểu xem",
+ "fx.gridView": "Biểu tượng",
+ "fx.listView": "Danh sách",
+ "fx.new": "Mới",
+ "fx.upload": "Tải lên",
+ "fx.more": "Thêm",
+ "fx.selectAll": "Chọn tất cả",
+ "fx.showHidden": "Hiện file ẩn (Ctrl+H)",
+ "fx.untitledFolder": "Thư mục mới",
+ "fx.untitledFile": "chua-dat-ten.txt",
+ "fx.copyPath": "Sao chép đường dẫn",
+ "fx.clearSelection": "Bỏ chọn",
+ "fx.noMatch": "Không có mục nào khớp.",
+ "fx.loading": "Đang tải…",
+ "fx.dropHere": "Thả để tải lên vào {{folder}}",
+ "fx.nameField": "Tên",
+ "fx.from": "Từ",
+ "fx.deletedAt": "Ngày xoá",
+ "fx.unavailable": "Dashboard này chưa có trình quản lý file — cần khởi động lại sau khi cập nhật.",
+ "fx.noWorkspace": "Chưa có workspace nào. Hãy thêm một workspace, file của nó sẽ hiện ở đây.",
+ "fx.myHome": "Thư mục home",
+ "fx.userHomes": "Home của các user",
+ "fx.readOnlyHome": "Home của user khác — chỉ xem và tải về",
+ "fx.readOnly": "Chỉ đọc",
+ "fx.readOnlyHint": "Đây là home của user khác: bạn chỉ xem, sao chép và tải về được, không sửa được."
}
diff --git a/client/src/i18n/locales/vi/nav.json b/client/src/i18n/locales/vi/nav.json
index fd9d6cee..bc1e4226 100644
--- a/client/src/i18n/locales/vi/nav.json
+++ b/client/src/i18n/locales/vi/nav.json
@@ -85,5 +85,6 @@
"closeMenu": "Đóng menu",
"sidebar": "Thanh bên",
"now": "bây giờ",
- "users": "Người dùng"
+ "users": "Người dùng",
+ "files": "Quản lý file"
}
diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts
index a61ff056..fbb4674b 100644
--- a/client/src/lib/api.ts
+++ b/client/src/lib/api.ts
@@ -702,6 +702,8 @@ async function send(path: string, options?: RequestInit): Promise {
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` (same target as {@link gitUrl}) or `/homes/:who/fs`. */
+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(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("/me/git"),
diff --git a/client/src/pages/FilesPage.tsx b/client/src/pages/FilesPage.tsx
new file mode 100644
index 00000000..847960d8
--- /dev/null
+++ b/client/src/pages/FilesPage.tsx
@@ -0,0 +1,77 @@
+/**
+ * @file FilesPage.tsx
+ * @description `/files` — the file manager (`FileBrowser`) as a page of its
+ * own, reached from the sidebar's **Files** entry. The location is kept in the
+ * URL (`?home=&path=` or `?lane=&path=`), so Back and a shared
+ * link land in the same folder. With neither it opens the user's own HOME
+ * (their Linux home under S3), or — where they have none — the last
+ * workspace shown (`agenthub.workspace.selected`), else the first.
+ * @author Nguyễn Ngọc Trí Vĩ
+ */
+
+import { useEffect, useState } from "react";
+import { useSearchParams } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+import { api, isHomePlace, type FsPlace } from "../lib/api";
+import { FileBrowser } from "../components/lanes/FileExplorer";
+
+function lastWorkspace(): number | null {
+ try {
+ const v = Number(localStorage.getItem("agenthub.workspace.selected"));
+ return Number.isInteger(v) && v > 0 ? v : null;
+ } catch {
+ return null;
+ }
+}
+
+type Start = { lane: number } | { home: string };
+
+export function FilesPage() {
+ const { t } = useTranslation(["lanes"]);
+ const [params, setParams] = useSearchParams();
+ const askedLane = Number(params.get("lane"));
+ const askedHome = params.get("home");
+ const [start, setStart] = useState(
+ askedHome
+ ? { home: askedHome }
+ : Number.isInteger(askedLane) && askedLane > 0
+ ? { lane: askedLane }
+ : null
+ );
+ const [none, setNone] = useState(false);
+
+ useEffect(() => {
+ if (start) return;
+ void (async () => {
+ const homes = await api.files.homes().catch(() => ({ homes: [] }));
+ if (homes.homes.some((h) => h.id === "me")) return setStart({ home: "me" });
+ const { lanes } = await api.lanes.list().catch(() => ({ lanes: [] }));
+ const last = lastWorkspace();
+ const pick = lanes.find((l) => l.id === last) ?? lanes[0];
+ if (pick) setStart({ lane: pick.id });
+ else setNone(true);
+ })();
+ }, [start]);
+
+ if (none) return {t("fx.noWorkspace")}
;
+ if (!start) return null;
+ return (
+ // Cancels Layout's page padding, like the Workspace session view.
+
+ {
+ const next = new URLSearchParams(
+ isHomePlace(place)
+ ? { home: place.home }
+ : { lane: String(typeof place === "number" ? place : place.lane) }
+ );
+ if (path) next.set("path", path);
+ setParams(next, { replace: false });
+ }}
+ />
+
+ );
+}
diff --git a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap
index 2bbed7c1..0b6ff3ec 100644
--- a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap
+++ b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap
@@ -7178,7 +7178,7 @@ exports[`screen snapshots > Settings — about 1`] = `
- UI build v2.7.6 (rebuild client to match)
+ UI build v2.7.7 (rebuild client to match)
diff --git a/docs/API.md b/docs/API.md
index 571a449c..6e7c620d 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -258,6 +258,61 @@ folder name inside an existing folder, `400 EBADNAME`/`ENOTFOUND`,
not at all without one (`403 EFOLDER`); a member's folder is made as the
member (S3).
+#### File Explorer
+
+```
+GET /api/lanes/:id/fs/list?path= { path, parent, entries: [{ name, path, type: dir|file|link|linkdir|other, size, mtime }], truncated }
+POST /api/lanes/:id/fs/mkdir { path } → 201 { path }
+POST /api/lanes/:id/fs/newfile { path } → 201 { path } (empty file)
+POST /api/lanes/:id/fs/rename { path, name } → { path }
+POST /api/lanes/:id/fs/move { paths, dest, conflict? } → { moved }
+POST /api/lanes/:id/fs/copy { paths, dest, conflict?, fromLane? } → { copied }
+POST /api/lanes/:id/fs/delete { paths } → { trashed: [ids] }
+GET /api/lanes/:id/fs/trash → { items: [{ id, name, laneId, path, isDir, deletedAt }], keepDays }
+POST /api/lanes/:id/fs/trash/restore { id } → { path, laneId }
+POST /api/lanes/:id/fs/trash/purge { id | "all" } → { purged }
+POST /api/lanes/:id/fs/upload?path=&conflict= raw body, streamed → 201 { path } | 200 { skipped: true }
+GET /api/lanes/:id/fs/zip?p=&p=… application/zip, streamed
+```
+
+The workspace **File Explorer** — the **Files** page in the sidebar (`/files?lane=&path=`), laid out like GNOME Files (workspaces as places, Back/Forward, a path bar, icon grid or list, hidden files behind Ctrl+H), and the same manager in a dialog from a workspace's Files tab (the folder button; "Open in File Explorer" on a folder). `?session=` / `?source=` pick the same folder the
+git reads do. Paths go through the read path's guards (`lanePath`, the parent
+`resolveInLane`-confined, the last segment kept as a name so a symlink is acted
+on as a link); nothing in `.git`, no secret file, never the workspace folder
+itself (`400 EBADPATH` / `403 ESECRET`). Writes are same-origin and run as the
+folder's account (a member's through `write-as`). `conflict` = `fail`
+(default: `409 EEXISTS` with `error.names`), `skip`, `keep` (a free
+"name (1).ext") or `replace` (the old one goes to the trash first). Delete
+moves into the account's trash — a member's `~/.local/share/agenthub/trash/`,
+the host's `/trash//` — restorable for 7 days. A copy may
+come `fromLane` another workspace the caller can see, of the same account
+(`403 EFOREIGN` otherwise). An upload is at most 500 MB (`413 ETOOBIG`, from
+`Content-Length` before a byte is stored), a zip at most 2 GB / 50,000 files.
+Under S3 a move/copy/delete needs the installed `write-as` to know
+`move`/`copy`: `409 EUPGRADE` until `sudo agenthub server install-system` is
+re-run.
+
+#### File Explorer on a home
+
+```
+GET /api/homes { homes: [{ id: "me" | , label, readOnly }] }
+GET /api/homes/:who/fs/list|trash|zip|download as the workspace routes above
+POST /api/homes/:who/fs/mkdir|newfile|rename|move|copy|delete|upload|trash/restore|trash/purge
+```
+
+The same File Explorer on a user's HOME (`server/lib/homes.js`): under S3 their
+own Linux home, in server mode without S3 their folder under
+`AGENTHUB_USERS_ROOT`, in local mode (or for an admin with no Linux account)
+the host account's home. Every dot entry at the TOP of a home (`.ssh`,
+`.claude`, `.config`, `.agenthub`, `.bashrc`…) is hidden from listings and
+refused for every read and write — also through a symlink, a download or a
+zip (`403 ESECRET`); dot files deeper down are ordinary. `:who` is `me`; an
+admin may also name another user — read-only (`403 EREADONLY` on every
+write), each read written to the audit log. A copy goes home ↔ workspace
+(`fromHome` on the workspace route, `fromLane` on the home one) when both are
+one Linux account; each side is checked under its own rule. A home is never a
+workspace. The **Files** page opens on `?home=me` by default.
+
#### Clone a repository into a new workspace
```
diff --git a/docs/SERVER-MODE.md b/docs/SERVER-MODE.md
index 2139e8a7..9efc0e5e 100644
--- a/docs/SERVER-MODE.md
+++ b/docs/SERVER-MODE.md
@@ -713,6 +713,20 @@ Every command below runs as the **host account** (the one that runs the
dashboard) unless it says `sudo`. `agenthub server install-system` calls sudo
itself, once.
+### After an update that changes the member helpers
+
+`write-as`, `pane-launch` and the sudoers file are copied to
+`/opt/agenthub/libexec` by `install-system` only — an update of AgentHub does
+not touch root-owned files. When a release changes them (v2.7.7: `write-as`
+learned `move`/`copy` for the File Explorer), re-run it once:
+
+```bash
+sudo agenthub server install-system
+```
+
+Until then the File Explorer's move, copy and delete in a member's folder
+answer `409 EUPGRADE` (listing, upload, new folder/file and zip work).
+
### What a new machine needs before `install-system`
`install-system` does not do any of this:
@@ -955,6 +969,8 @@ To remove everything:
| `409 ENOTSHARED` on a member's start | That tool is not published to `/opt/agenthub/tools` yet: `agenthub server tools`. |
| `403 EMEMBERLANE` | Worktree mode and the runtime layer are not offered in a member's folder; use Repo mode and the prompt box's New worktree. |
| `400 EMEMBERCRED` | Older builds only: a member's typed git login was refused. Now it goes into their own store (Settings → Git → **Your git identity**). |
+| `409 EOWNERFOLDER` (on a write to an existing workspace) | Its owner may no longer own that folder by their CURRENT role — typically an admin demoted to developer whose workspace is outside the users root (it runs as the host). Reads still work; an admin reassigns the workspace or restores the role. |
+| `403 EGATEWAY` | A request relayed by the AI Proxy gateway reached the dashboard itself (a proxy profile whose base URL points back at AgentHub). Point the profile at the real proxy. |
| `409 ESTOREUNREADABLE` | The dashboard cannot read the member's `~/.config/agenthub/git-credentials` (the unit lost `CAP_DAC_READ_SEARCH`, or the file is not the member's own regular file); nothing is rewritten. Check `grep CapAmb /proc//status` shows `0000000000000004`. |
| `403 EPEER` on a hook | The hook came from an account that is neither the host nor an enabled member, or the uid could not be read. |
| `403 EFOREIGN` on a hook | A member's hook named a folder, session or pane outside their own home. |
diff --git a/openapi.yaml b/openapi.yaml
index c739c4e4..bc751192 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -3,7 +3,7 @@
openapi: 3.0.3
info:
title: Agent Dashboard for Claude Code API
- version: 2.7.6
+ version: 2.7.7
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
contact:
name: Nguyễn Ngọc Trí Vĩ
@@ -64,6 +64,10 @@ tags:
description: The catalog of AI coding CLIs a lane can run (server/data/ai-tools/*.json + DASHBOARD_AI_TOOLS_DIR), what is installed on PATH, and the user's enable/default choice
- name: Auth
description: 'Server mode''s login (AGENTHUB_MODE=server). In local mode — the default — only GET /api/auth/me answers, with {mode: "local"}.'
+ - name: Files
+ description: 'The File Explorer on a workspace or a user''s home: list, make, rename, move, copy, delete into a restorable trash, streamed upload, zip download. Writes run as the folder''s account; a taken name is settled by `conflict`.'
+ - name: Git identity
+ description: A user's own SSH key and HTTPS logins and commit author, and cloning a repository into a new workspace with them.
components:
parameters:
SessionIdPath:
@@ -4855,6 +4859,51 @@ components:
nullable: true
allOf:
- $ref: '#/components/schemas/AuthUser'
+ FsEntry:
+ type: object
+ properties:
+ name:
+ type: string
+ path:
+ type: string
+ description: Relative to the workspace or home.
+ type:
+ type: string
+ enum:
+ - dir
+ - file
+ - link
+ - linkdir
+ - other
+ size:
+ type: integer
+ nullable: true
+ mtime:
+ type: number
+ description: Milliseconds since the epoch.
+ FsListing:
+ type: object
+ properties:
+ path:
+ type: string
+ parent:
+ type: string
+ nullable: true
+ entries:
+ type: array
+ items:
+ $ref: '#/components/schemas/FsEntry'
+ truncated:
+ type: boolean
+ FsConflict:
+ type: string
+ enum:
+ - fail
+ - skip
+ - keep
+ - replace
+ default: fail
+ description: 'A taken name: `fail` answers 409 EEXISTS with the names, `skip` leaves it, `keep` uses a free `name (1).ext`, `replace` sends the old one to the trash first.'
paths:
/api/health:
get:
@@ -14938,6 +14987,874 @@ paths:
properties:
ok:
type: boolean
+ /api/lanes/{id}/fs/list:
+ get:
+ tags:
+ - Files
+ summary: List a folder (folders first)
+ description: On a workspace (`?session=` / `?source=` pick the session worktree / extra folder, like the git reads). 400 EBADPATH/EBADNAME, 403 ESECRET/EFOREIGN/EREADONLY (another user's home)/EBADORIGIN, 404 ENOFILE/ENOHOME, 409 EEXISTS (with `error.names`)/EUPGRADE/EMEMBERGONE, 413 ETOOBIG.
+ operationId: laneFsList
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: integer
+ - name: path
+ in: query
+ schema:
+ type: string
+ default: ''
+ responses:
+ '200':
+ description: The folder.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/FsListing'
+ /api/lanes/{id}/fs/mkdir:
+ post:
+ tags:
+ - Files
+ summary: Make a folder
+ description: On a workspace (`?session=` / `?source=` pick the session worktree / extra folder, like the git reads). 400 EBADPATH/EBADNAME, 403 ESECRET/EFOREIGN/EREADONLY (another user's home)/EBADORIGIN, 404 ENOFILE/ENOHOME, 409 EEXISTS (with `error.names`)/EUPGRADE/EMEMBERGONE, 413 ETOOBIG.
+ operationId: laneFsMkdir
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: integer
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - path
+ properties:
+ path:
+ type: string
+ responses:
+ '201':
+ description: Done.
+ /api/lanes/{id}/fs/newfile:
+ post:
+ tags:
+ - Files
+ summary: Make an empty file
+ description: On a workspace (`?session=` / `?source=` pick the session worktree / extra folder, like the git reads). 400 EBADPATH/EBADNAME, 403 ESECRET/EFOREIGN/EREADONLY (another user's home)/EBADORIGIN, 404 ENOFILE/ENOHOME, 409 EEXISTS (with `error.names`)/EUPGRADE/EMEMBERGONE, 413 ETOOBIG.
+ operationId: laneFsNewfile
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: integer
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - path
+ properties:
+ path:
+ type: string
+ responses:
+ '201':
+ description: Done.
+ /api/lanes/{id}/fs/rename:
+ post:
+ tags:
+ - Files
+ summary: Rename in place
+ description: On a workspace (`?session=` / `?source=` pick the session worktree / extra folder, like the git reads). 400 EBADPATH/EBADNAME, 403 ESECRET/EFOREIGN/EREADONLY (another user's home)/EBADORIGIN, 404 ENOFILE/ENOHOME, 409 EEXISTS (with `error.names`)/EUPGRADE/EMEMBERGONE, 413 ETOOBIG.
+ operationId: laneFsRename
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: integer
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - path
+ - name
+ properties:
+ path:
+ type: string
+ name:
+ type: string
+ responses:
+ '200':
+ description: Done.
+ /api/lanes/{id}/fs/move:
+ post:
+ tags:
+ - Files
+ summary: Move items into a folder
+ description: On a workspace (`?session=` / `?source=` pick the session worktree / extra folder, like the git reads). 400 EBADPATH/EBADNAME, 403 ESECRET/EFOREIGN/EREADONLY (another user's home)/EBADORIGIN, 404 ENOFILE/ENOHOME, 409 EEXISTS (with `error.names`)/EUPGRADE/EMEMBERGONE, 413 ETOOBIG.
+ operationId: laneFsMove
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: integer
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - paths
+ - dest
+ properties:
+ paths:
+ type: array
+ items:
+ type: string
+ dest:
+ type: string
+ conflict:
+ $ref: '#/components/schemas/FsConflict'
+ responses:
+ '200':
+ description: Done.
+ /api/lanes/{id}/fs/copy:
+ post:
+ tags:
+ - Files
+ summary: Copy items into a folder — from here, another workspace (`fromLane`) or a home (`fromHome`) of the same account
+ description: On a workspace (`?session=` / `?source=` pick the session worktree / extra folder, like the git reads). 400 EBADPATH/EBADNAME, 403 ESECRET/EFOREIGN/EREADONLY (another user's home)/EBADORIGIN, 404 ENOFILE/ENOHOME, 409 EEXISTS (with `error.names`)/EUPGRADE/EMEMBERGONE, 413 ETOOBIG.
+ operationId: laneFsCopy
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: integer
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - paths
+ - dest
+ properties:
+ paths:
+ type: array
+ items:
+ type: string
+ dest:
+ type: string
+ conflict:
+ $ref: '#/components/schemas/FsConflict'
+ fromLane:
+ type: integer
+ fromHome:
+ type: string
+ responses:
+ '200':
+ description: Done.
+ /api/lanes/{id}/fs/delete:
+ post:
+ tags:
+ - Files
+ summary: Move items into the account's trash (restorable for 7 days)
+ description: On a workspace (`?session=` / `?source=` pick the session worktree / extra folder, like the git reads). 400 EBADPATH/EBADNAME, 403 ESECRET/EFOREIGN/EREADONLY (another user's home)/EBADORIGIN, 404 ENOFILE/ENOHOME, 409 EEXISTS (with `error.names`)/EUPGRADE/EMEMBERGONE, 413 ETOOBIG.
+ operationId: laneFsDelete
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: integer
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - paths
+ properties:
+ paths:
+ type: array
+ items:
+ type: string
+ responses:
+ '200':
+ description: Done.
+ /api/lanes/{id}/fs/trash:
+ get:
+ tags:
+ - Files
+ summary: The trash, newest first (entries older than 7 days are dropped)
+ description: On a workspace (`?session=` / `?source=` pick the session worktree / extra folder, like the git reads). 400 EBADPATH/EBADNAME, 403 ESECRET/EFOREIGN/EREADONLY (another user's home)/EBADORIGIN, 404 ENOFILE/ENOHOME, 409 EEXISTS (with `error.names`)/EUPGRADE/EMEMBERGONE, 413 ETOOBIG.
+ operationId: laneFsTrash
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: integer
+ responses:
+ '200':
+ description: Done.
+ /api/lanes/{id}/fs/trash/restore:
+ post:
+ tags:
+ - Files
+ summary: Put a trash item back where it was
+ description: On a workspace (`?session=` / `?source=` pick the session worktree / extra folder, like the git reads). 400 EBADPATH/EBADNAME, 403 ESECRET/EFOREIGN/EREADONLY (another user's home)/EBADORIGIN, 404 ENOFILE/ENOHOME, 409 EEXISTS (with `error.names`)/EUPGRADE/EMEMBERGONE, 413 ETOOBIG.
+ operationId: laneFsTrashRestore
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: integer
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - id
+ properties:
+ id:
+ type: string
+ responses:
+ '200':
+ description: Done.
+ /api/lanes/{id}/fs/trash/purge:
+ post:
+ tags:
+ - Files
+ summary: Delete a trash item (or `all`) for good
+ description: On a workspace (`?session=` / `?source=` pick the session worktree / extra folder, like the git reads). 400 EBADPATH/EBADNAME, 403 ESECRET/EFOREIGN/EREADONLY (another user's home)/EBADORIGIN, 404 ENOFILE/ENOHOME, 409 EEXISTS (with `error.names`)/EUPGRADE/EMEMBERGONE, 413 ETOOBIG.
+ operationId: laneFsTrashPurge
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: integer
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - id
+ properties:
+ id:
+ type: string
+ responses:
+ '200':
+ description: Done.
+ /api/lanes/{id}/fs/upload:
+ post:
+ tags:
+ - Files
+ summary: Upload one file, streamed, making missing sub-folders (≤ 500 MB)
+ description: On a workspace (`?session=` / `?source=` pick the session worktree / extra folder, like the git reads). 400 EBADPATH/EBADNAME, 403 ESECRET/EFOREIGN/EREADONLY (another user's home)/EBADORIGIN, 404 ENOFILE/ENOHOME, 409 EEXISTS (with `error.names`)/EUPGRADE/EMEMBERGONE, 413 ETOOBIG.
+ operationId: laneFsUpload
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: integer
+ - name: path
+ in: query
+ required: true
+ schema:
+ type: string
+ - name: conflict
+ in: query
+ schema:
+ $ref: '#/components/schemas/FsConflict'
+ requestBody:
+ required: true
+ content:
+ application/octet-stream:
+ schema:
+ type: string
+ format: binary
+ responses:
+ '201':
+ description: Done.
+ /api/lanes/{id}/fs/zip:
+ get:
+ tags:
+ - Files
+ summary: Files and folders as one streamed zip (≤ 2 GB, 50,000 files)
+ description: On a workspace (`?session=` / `?source=` pick the session worktree / extra folder, like the git reads). 400 EBADPATH/EBADNAME, 403 ESECRET/EFOREIGN/EREADONLY (another user's home)/EBADORIGIN, 404 ENOFILE/ENOHOME, 409 EEXISTS (with `error.names`)/EUPGRADE/EMEMBERGONE, 413 ETOOBIG.
+ operationId: laneFsZip
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: integer
+ - name: p
+ in: query
+ required: true
+ schema:
+ type: array
+ items:
+ type: string
+ style: form
+ explode: true
+ responses:
+ '200':
+ description: application/zip
+ content:
+ application/zip:
+ schema:
+ type: string
+ format: binary
+ /api/homes/{who}/fs/list:
+ get:
+ tags:
+ - Files
+ summary: List a folder (folders first)
+ description: On a user's home. Every dot entry at the top of the home is hidden and refused. 400 EBADPATH/EBADNAME, 403 ESECRET/EFOREIGN/EREADONLY (another user's home)/EBADORIGIN, 404 ENOFILE/ENOHOME, 409 EEXISTS (with `error.names`)/EUPGRADE/EMEMBERGONE, 413 ETOOBIG.
+ operationId: homeFsList
+ parameters:
+ - name: who
+ in: path
+ required: true
+ schema:
+ type: string
+ description: '`me`, or another user''s name (an admin''s read-only view).'
+ - name: path
+ in: query
+ schema:
+ type: string
+ default: ''
+ responses:
+ '200':
+ description: The folder.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/FsListing'
+ /api/homes/{who}/fs/mkdir:
+ post:
+ tags:
+ - Files
+ summary: Make a folder
+ description: On a user's home. Every dot entry at the top of the home is hidden and refused. 400 EBADPATH/EBADNAME, 403 ESECRET/EFOREIGN/EREADONLY (another user's home)/EBADORIGIN, 404 ENOFILE/ENOHOME, 409 EEXISTS (with `error.names`)/EUPGRADE/EMEMBERGONE, 413 ETOOBIG.
+ operationId: homeFsMkdir
+ parameters:
+ - name: who
+ in: path
+ required: true
+ schema:
+ type: string
+ description: '`me`, or another user''s name (an admin''s read-only view).'
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - path
+ properties:
+ path:
+ type: string
+ responses:
+ '201':
+ description: Done.
+ /api/homes/{who}/fs/newfile:
+ post:
+ tags:
+ - Files
+ summary: Make an empty file
+ description: On a user's home. Every dot entry at the top of the home is hidden and refused. 400 EBADPATH/EBADNAME, 403 ESECRET/EFOREIGN/EREADONLY (another user's home)/EBADORIGIN, 404 ENOFILE/ENOHOME, 409 EEXISTS (with `error.names`)/EUPGRADE/EMEMBERGONE, 413 ETOOBIG.
+ operationId: homeFsNewfile
+ parameters:
+ - name: who
+ in: path
+ required: true
+ schema:
+ type: string
+ description: '`me`, or another user''s name (an admin''s read-only view).'
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - path
+ properties:
+ path:
+ type: string
+ responses:
+ '201':
+ description: Done.
+ /api/homes/{who}/fs/rename:
+ post:
+ tags:
+ - Files
+ summary: Rename in place
+ description: On a user's home. Every dot entry at the top of the home is hidden and refused. 400 EBADPATH/EBADNAME, 403 ESECRET/EFOREIGN/EREADONLY (another user's home)/EBADORIGIN, 404 ENOFILE/ENOHOME, 409 EEXISTS (with `error.names`)/EUPGRADE/EMEMBERGONE, 413 ETOOBIG.
+ operationId: homeFsRename
+ parameters:
+ - name: who
+ in: path
+ required: true
+ schema:
+ type: string
+ description: '`me`, or another user''s name (an admin''s read-only view).'
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - path
+ - name
+ properties:
+ path:
+ type: string
+ name:
+ type: string
+ responses:
+ '200':
+ description: Done.
+ /api/homes/{who}/fs/move:
+ post:
+ tags:
+ - Files
+ summary: Move items into a folder
+ description: On a user's home. Every dot entry at the top of the home is hidden and refused. 400 EBADPATH/EBADNAME, 403 ESECRET/EFOREIGN/EREADONLY (another user's home)/EBADORIGIN, 404 ENOFILE/ENOHOME, 409 EEXISTS (with `error.names`)/EUPGRADE/EMEMBERGONE, 413 ETOOBIG.
+ operationId: homeFsMove
+ parameters:
+ - name: who
+ in: path
+ required: true
+ schema:
+ type: string
+ description: '`me`, or another user''s name (an admin''s read-only view).'
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - paths
+ - dest
+ properties:
+ paths:
+ type: array
+ items:
+ type: string
+ dest:
+ type: string
+ conflict:
+ $ref: '#/components/schemas/FsConflict'
+ responses:
+ '200':
+ description: Done.
+ /api/homes/{who}/fs/copy:
+ post:
+ tags:
+ - Files
+ summary: Copy items into a folder — from here, another workspace (`fromLane`) or a home (`fromHome`) of the same account
+ description: On a user's home. Every dot entry at the top of the home is hidden and refused. 400 EBADPATH/EBADNAME, 403 ESECRET/EFOREIGN/EREADONLY (another user's home)/EBADORIGIN, 404 ENOFILE/ENOHOME, 409 EEXISTS (with `error.names`)/EUPGRADE/EMEMBERGONE, 413 ETOOBIG.
+ operationId: homeFsCopy
+ parameters:
+ - name: who
+ in: path
+ required: true
+ schema:
+ type: string
+ description: '`me`, or another user''s name (an admin''s read-only view).'
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - paths
+ - dest
+ properties:
+ paths:
+ type: array
+ items:
+ type: string
+ dest:
+ type: string
+ conflict:
+ $ref: '#/components/schemas/FsConflict'
+ fromLane:
+ type: integer
+ fromHome:
+ type: string
+ responses:
+ '200':
+ description: Done.
+ /api/homes/{who}/fs/delete:
+ post:
+ tags:
+ - Files
+ summary: Move items into the account's trash (restorable for 7 days)
+ description: On a user's home. Every dot entry at the top of the home is hidden and refused. 400 EBADPATH/EBADNAME, 403 ESECRET/EFOREIGN/EREADONLY (another user's home)/EBADORIGIN, 404 ENOFILE/ENOHOME, 409 EEXISTS (with `error.names`)/EUPGRADE/EMEMBERGONE, 413 ETOOBIG.
+ operationId: homeFsDelete
+ parameters:
+ - name: who
+ in: path
+ required: true
+ schema:
+ type: string
+ description: '`me`, or another user''s name (an admin''s read-only view).'
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - paths
+ properties:
+ paths:
+ type: array
+ items:
+ type: string
+ responses:
+ '200':
+ description: Done.
+ /api/homes/{who}/fs/trash:
+ get:
+ tags:
+ - Files
+ summary: The trash, newest first (entries older than 7 days are dropped)
+ description: On a user's home. Every dot entry at the top of the home is hidden and refused. 400 EBADPATH/EBADNAME, 403 ESECRET/EFOREIGN/EREADONLY (another user's home)/EBADORIGIN, 404 ENOFILE/ENOHOME, 409 EEXISTS (with `error.names`)/EUPGRADE/EMEMBERGONE, 413 ETOOBIG.
+ operationId: homeFsTrash
+ parameters:
+ - name: who
+ in: path
+ required: true
+ schema:
+ type: string
+ description: '`me`, or another user''s name (an admin''s read-only view).'
+ responses:
+ '200':
+ description: Done.
+ /api/homes/{who}/fs/trash/restore:
+ post:
+ tags:
+ - Files
+ summary: Put a trash item back where it was
+ description: On a user's home. Every dot entry at the top of the home is hidden and refused. 400 EBADPATH/EBADNAME, 403 ESECRET/EFOREIGN/EREADONLY (another user's home)/EBADORIGIN, 404 ENOFILE/ENOHOME, 409 EEXISTS (with `error.names`)/EUPGRADE/EMEMBERGONE, 413 ETOOBIG.
+ operationId: homeFsTrashRestore
+ parameters:
+ - name: who
+ in: path
+ required: true
+ schema:
+ type: string
+ description: '`me`, or another user''s name (an admin''s read-only view).'
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - id
+ properties:
+ id:
+ type: string
+ responses:
+ '200':
+ description: Done.
+ /api/homes/{who}/fs/trash/purge:
+ post:
+ tags:
+ - Files
+ summary: Delete a trash item (or `all`) for good
+ description: On a user's home. Every dot entry at the top of the home is hidden and refused. 400 EBADPATH/EBADNAME, 403 ESECRET/EFOREIGN/EREADONLY (another user's home)/EBADORIGIN, 404 ENOFILE/ENOHOME, 409 EEXISTS (with `error.names`)/EUPGRADE/EMEMBERGONE, 413 ETOOBIG.
+ operationId: homeFsTrashPurge
+ parameters:
+ - name: who
+ in: path
+ required: true
+ schema:
+ type: string
+ description: '`me`, or another user''s name (an admin''s read-only view).'
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - id
+ properties:
+ id:
+ type: string
+ responses:
+ '200':
+ description: Done.
+ /api/homes/{who}/fs/upload:
+ post:
+ tags:
+ - Files
+ summary: Upload one file, streamed, making missing sub-folders (≤ 500 MB)
+ description: On a user's home. Every dot entry at the top of the home is hidden and refused. 400 EBADPATH/EBADNAME, 403 ESECRET/EFOREIGN/EREADONLY (another user's home)/EBADORIGIN, 404 ENOFILE/ENOHOME, 409 EEXISTS (with `error.names`)/EUPGRADE/EMEMBERGONE, 413 ETOOBIG.
+ operationId: homeFsUpload
+ parameters:
+ - name: who
+ in: path
+ required: true
+ schema:
+ type: string
+ description: '`me`, or another user''s name (an admin''s read-only view).'
+ - name: path
+ in: query
+ required: true
+ schema:
+ type: string
+ - name: conflict
+ in: query
+ schema:
+ $ref: '#/components/schemas/FsConflict'
+ requestBody:
+ required: true
+ content:
+ application/octet-stream:
+ schema:
+ type: string
+ format: binary
+ responses:
+ '201':
+ description: Done.
+ /api/homes/{who}/fs/zip:
+ get:
+ tags:
+ - Files
+ summary: Files and folders as one streamed zip (≤ 2 GB, 50,000 files)
+ description: On a user's home. Every dot entry at the top of the home is hidden and refused. 400 EBADPATH/EBADNAME, 403 ESECRET/EFOREIGN/EREADONLY (another user's home)/EBADORIGIN, 404 ENOFILE/ENOHOME, 409 EEXISTS (with `error.names`)/EUPGRADE/EMEMBERGONE, 413 ETOOBIG.
+ operationId: homeFsZip
+ parameters:
+ - name: who
+ in: path
+ required: true
+ schema:
+ type: string
+ description: '`me`, or another user''s name (an admin''s read-only view).'
+ - name: p
+ in: query
+ required: true
+ schema:
+ type: array
+ items:
+ type: string
+ style: form
+ explode: true
+ responses:
+ '200':
+ description: application/zip
+ content:
+ application/zip:
+ schema:
+ type: string
+ format: binary
+ /api/homes/{who}/fs/download:
+ get:
+ tags:
+ - Files
+ summary: Download one file of a home
+ operationId: homeFsDownload
+ parameters:
+ - name: who
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: path
+ in: query
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: The file, as a sandboxed attachment.
+ /api/homes:
+ get:
+ tags:
+ - Files
+ summary: The homes the caller may open
+ description: '`me` (their own), and for an admin every other user''s — read-only.'
+ operationId: listHomes
+ responses:
+ '200':
+ description: '{homes: [{id, label, readOnly}]}'
+ /api/lanes/clone:
+ post:
+ tags:
+ - Git identity
+ summary: Clone a repository into a new folder and make it a workspace
+ description: '`url`: https:// (no userinfo), ssh:// or user@host:path. Runs as the folder''s account with that account''s own SSH key or HTTPS login. 409 ECREDENTIALS (`error.auth` names the host) asks for `credentials`; 409 ESSHAUTH: the key was refused; 409 ENOREPO: no such repository.'
+ operationId: cloneLane
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - url
+ - parent
+ properties:
+ url:
+ type: string
+ parent:
+ type: string
+ name:
+ type: string
+ title:
+ type: string
+ tool:
+ type: string
+ credentials:
+ type: object
+ properties:
+ username:
+ type: string
+ password:
+ type: string
+ remember:
+ type: boolean
+ responses:
+ '201':
+ description: '{lane, created: true, path}'
+ /api/me/git:
+ get:
+ tags:
+ - Git identity
+ summary: The caller's own git identity
+ description: Only the public SSH key and `hasToken` are ever returned.
+ operationId: getGitIdentity
+ responses:
+ '200':
+ description: '{account, writable, ssh: {publicKey, fingerprint} | null, https: [...], author: {name, email}}'
+ /api/me/git/ssh-key:
+ post:
+ tags:
+ - Git identity
+ summary: Make (or with `replace`, replace) the SSH key
+ operationId: generateSshKey
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required: []
+ properties:
+ replace:
+ type: boolean
+ responses:
+ '201':
+ description: The identity.
+ delete:
+ tags:
+ - Git identity
+ summary: Delete the SSH key
+ operationId: deleteSshKey
+ responses:
+ '200':
+ description: The identity.
+ /api/me/git/https:
+ post:
+ tags:
+ - Git identity
+ summary: Save an HTTPS login (never returned)
+ operationId: saveHttpsLogin
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - host
+ - username
+ - password
+ properties:
+ host:
+ type: string
+ username:
+ type: string
+ password:
+ type: string
+ responses:
+ '200':
+ description: The identity.
+ /api/me/git/https/{host}:
+ delete:
+ tags:
+ - Git identity
+ summary: Delete an HTTPS login
+ operationId: deleteHttpsLogin
+ parameters:
+ - name: host
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: The identity.
+ /api/me/git/author:
+ put:
+ tags:
+ - Git identity
+ summary: Set the commit author (`git config --global user.name/email`)
+ operationId: setGitAuthor
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - name
+ - email
+ properties:
+ name:
+ type: string
+ email:
+ type: string
+ responses:
+ '200':
+ description: The identity.
+ '400':
+ description: EBADAUTHOR
/api/settings/import:
post:
tags:
diff --git a/package-lock.json b/package-lock.json
index 4de8f05f..c0cd4021 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "agenthub",
- "version": "2.7.6",
+ "version": "2.7.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
- "version": "2.7.6",
+ "version": "2.7.7",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
diff --git a/package.json b/package.json
index c3b9e003..a623246e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "agenthub",
- "version": "2.7.6",
+ "version": "2.7.7",
"bin": {
"agenthub": "bin/agenthub.js"
},
diff --git a/scripts/server/write-as.sh b/scripts/server/write-as.sh
index de7b7035..803400d1 100644
--- a/scripts/server/write-as.sh
+++ b/scripts/server/write-as.sh
@@ -16,16 +16,27 @@
# private same, 0600
# mkdir the directory, 0755, parents included
# remove the path, recursively (rm never follows a symlink it removes)
+# move the path to the 3rd argument (rename; never onto an existing
+# name — checked just before, and the dashboard checks first)
+# copy the path, recursively, to the 3rd argument (symlinks copied as
+# links, never followed; mode and times kept; never onto an
+# existing name)
# @author Nguyễn Ngọc Trí Vĩ
set -eu
-if [ $# -lt 1 ] || [ $# -gt 2 ]; then
- echo "usage: write-as [append|replace|private|mkdir|remove]" >&2
+usage() {
+ echo "usage: write-as [append|replace|private|mkdir|remove] | move|copy " >&2
exit 2
-fi
-case "$1" in
- /*) ;;
- *) echo "write-as: an absolute path is required" >&2; exit 2 ;;
+}
+case "${2:-}" in
+ move|copy) [ $# -eq 3 ] || usage ;;
+ *) { [ $# -ge 1 ] && [ $# -le 2 ]; } || usage ;;
esac
+for p in "$1" "${3:-/}"; do
+ case "$p" in
+ /*) ;;
+ *) echo "write-as: an absolute path is required" >&2; exit 2 ;;
+ esac
+done
swap() {
t=$(mktemp "$1.XXXXXX")
cat >"$t" && chmod "$2" "$t" && mv -f "$t" "$1" || { rm -f "$t"; exit 1; }
@@ -37,5 +48,11 @@ case "${2:-}" in
private) swap "$1" 0600 ;;
mkdir) exec mkdir -p -m 0755 -- "$1" ;;
remove) exec rm -rf -- "$1" ;;
+ move)
+ [ -e "$3" ] || [ -L "$3" ] && { echo "write-as: $3 exists" >&2; exit 1; }
+ exec mv -T -- "$1" "$3" ;;
+ copy)
+ [ -e "$3" ] || [ -L "$3" ] && { echo "write-as: $3 exists" >&2; exit 1; }
+ exec cp -R -P -T --preserve=mode,timestamps -- "$1" "$3" ;;
*) echo "write-as: unknown mode: $2" >&2; exit 2 ;;
esac
diff --git a/server/__tests__/file-explorer.test.js b/server/__tests__/file-explorer.test.js
new file mode 100644
index 00000000..6ea1e022
--- /dev/null
+++ b/server/__tests__/file-explorer.test.js
@@ -0,0 +1,297 @@
+/**
+ * @file Route tests for the workspace File Explorer (`/api/lanes/:id/fs/*`,
+ * server/lib/file-explorer.js): list, mkdir/newfile, rename, move, copy (in
+ * place, across workspaces), delete into the trash and restore/purge, a
+ * streamed upload into new sub-folders with every conflict policy, a zip of
+ * files and folders that unzips back to the same bytes — and the guards: no
+ * `..`, no symlink walking out, nothing in `.git`, no secret file, the
+ * workspace folder itself never moved or deleted, cross-origin refused.
+ * @author Nguyễn Ngọc Trí Vĩ
+ */
+
+const { describe, it, before, after } = require("node:test");
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const os = require("node:os");
+const path = require("node:path");
+const http = require("node:http");
+
+const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "file-explorer-"));
+process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
+process.env.CLAUDE_HOME = path.join(ROOT, ".claude");
+process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "data");
+process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
+process.env.DASHBOARD_LIVENESS_PROBE = "0";
+process.env.HOME = ROOT;
+
+const { createApp } = require("../index");
+const AdmZip = require("adm-zip");
+
+let app;
+let base;
+const W1 = path.join(ROOT, "w1");
+const W2 = path.join(ROOT, "w2");
+let lane1;
+let lane2;
+
+function request(method, p, body, headers = {}) {
+ const raw = Buffer.isBuffer(body) || typeof body === "string";
+ return fetch(`${base}${p}`, {
+ method,
+ headers: { ...(raw ? {} : { "Content-Type": "application/json" }), ...headers },
+ body: body === undefined ? undefined : raw ? body : JSON.stringify(body),
+ }).then(async (r) => {
+ const buf = Buffer.from(await r.arrayBuffer());
+ let json = null;
+ try {
+ json = JSON.parse(buf.toString("utf8"));
+ } catch {
+ /* binary */
+ }
+ return { status: r.status, body: json, buf, headers: r.headers };
+ });
+}
+const fsApi = (lane, op, body) => request("POST", `/api/lanes/${lane}/fs/${op}`, body);
+const ls = async (lane, p = "") =>
+ (await request("GET", `/api/lanes/${lane}/fs/list?path=${encodeURIComponent(p)}`)).body;
+const names = (l) => l.entries.map((e) => e.name);
+
+before(async () => {
+ for (const w of [W1, W2]) fs.mkdirSync(w, { recursive: true });
+ fs.writeFileSync(path.join(W1, "a.txt"), "alpha\n");
+ fs.mkdirSync(path.join(W1, "src/lib"), { recursive: true });
+ fs.writeFileSync(path.join(W1, "src/lib/x.js"), "x\n");
+ fs.mkdirSync(path.join(W1, ".git"));
+ fs.writeFileSync(path.join(W1, ".git/config"), "[core]\n");
+ fs.mkdirSync(path.join(W1, ".claude"));
+ fs.writeFileSync(path.join(W1, ".claude/settings.local.json"), '{"secret":1}');
+ fs.symlinkSync("/etc", path.join(W1, "out"));
+ app = http.createServer(createApp());
+ await new Promise((r) => app.listen(0, "127.0.0.1", r));
+ base = `http://127.0.0.1:${app.address().port}`;
+ lane1 = (await request("POST", "/api/lanes/ensure", { cwd: W1, exact: true })).body.lane.id;
+ lane2 = (await request("POST", "/api/lanes/ensure", { cwd: W2, exact: true })).body.lane.id;
+});
+after(async () => {
+ await new Promise((r) => app.close(r));
+});
+
+describe("File Explorer", () => {
+ it("lists folders first, never a secret, a symlink shown as a link", async () => {
+ const l = await ls(lane1);
+ assert.equal(l.path, "");
+ assert.equal(l.parent, null);
+ assert.deepEqual(
+ l.entries.filter((e) => e.type === "dir").map((e) => e.name),
+ [".claude", ".git", "src"]
+ );
+ assert.ok(names(l).includes("a.txt"));
+ assert.equal(l.entries.find((e) => e.name === "out").type, "link");
+ const claude = await ls(lane1, ".claude");
+ assert.ok(!names(claude).includes("settings.local.json"));
+ assert.equal((await ls(lane1, "src")).parent, "");
+ });
+
+ it("refuses paths that leave the workspace", async () => {
+ for (const p of ["../", "/etc", "out", "out/passwd", "src/../../x"]) {
+ const r = await request("GET", `/api/lanes/${lane1}/fs/list?path=${encodeURIComponent(p)}`);
+ assert.ok([400, 403, 404].includes(r.status), `${p} → ${r.status}`);
+ }
+ });
+
+ it("makes a folder and an empty file, refusing a taken name", async () => {
+ assert.equal((await fsApi(lane1, "mkdir", { path: "docs" })).status, 201);
+ assert.equal((await fsApi(lane1, "newfile", { path: "docs/readme.md" })).status, 201);
+ assert.equal(fs.readFileSync(path.join(W1, "docs/readme.md"), "utf8"), "");
+ const again = await fsApi(lane1, "mkdir", { path: "docs" });
+ assert.equal(again.status, 409);
+ assert.deepEqual(again.body.error.names, ["docs"]);
+ assert.equal((await fsApi(lane1, "mkdir", { path: ".git/hooks2" })).status, 400);
+ assert.equal((await fsApi(lane1, "newfile", { path: "a/b\\c" })).status, 404);
+ });
+
+ it("renames, never onto a taken name or out of the folder", async () => {
+ assert.equal(
+ (await fsApi(lane1, "rename", { path: "docs/readme.md", name: "README.md" })).status,
+ 200
+ );
+ assert.ok(fs.existsSync(path.join(W1, "docs/README.md")));
+ assert.equal(
+ (await fsApi(lane1, "rename", { path: "docs/README.md", name: "../a.txt" })).status,
+ 400
+ );
+ fs.writeFileSync(path.join(W1, "docs/other.md"), "o");
+ assert.equal(
+ (await fsApi(lane1, "rename", { path: "docs/other.md", name: "README.md" })).status,
+ 409
+ );
+ });
+
+ it("moves, asking about a taken name, and never a folder into itself", async () => {
+ fs.writeFileSync(path.join(W1, "src/a.txt"), "other alpha\n");
+ const clash = await fsApi(lane1, "move", { paths: ["a.txt"], dest: "src" });
+ assert.equal(clash.status, 409);
+ assert.deepEqual(clash.body.error.names, ["a.txt"]);
+ const keep = await fsApi(lane1, "move", { paths: ["a.txt"], dest: "src", conflict: "keep" });
+ assert.deepEqual(keep.body.moved, ["src/a (1).txt"]);
+ assert.equal((await fsApi(lane1, "move", { paths: ["src"], dest: "src/lib" })).status, 400);
+ assert.equal((await fsApi(lane1, "move", { paths: [".git"], dest: "docs" })).status, 400);
+ });
+
+ it("copies in place (keeping both) and into another workspace of the account", async () => {
+ const inPlace = await fsApi(lane1, "copy", { paths: ["src/lib/x.js"], dest: "src/lib" });
+ assert.deepEqual(inPlace.body.copied, ["src/lib/x (1).js"]);
+ const across = await request("POST", `/api/lanes/${lane2}/fs/copy`, {
+ paths: ["src"],
+ dest: "",
+ fromLane: lane1,
+ });
+ assert.equal(across.status, 200, JSON.stringify(across.body));
+ assert.equal(fs.readFileSync(path.join(W2, "src/lib/x.js"), "utf8"), "x\n");
+ // A symlink inside a copied folder stays a link — never followed.
+ fs.symlinkSync("/etc/hostname", path.join(W1, "docs/link"));
+ await request("POST", `/api/lanes/${lane2}/fs/copy`, {
+ paths: ["docs"],
+ dest: "",
+ fromLane: lane1,
+ });
+ assert.ok(fs.lstatSync(path.join(W2, "docs/link")).isSymbolicLink());
+ });
+
+ it("deletes into the trash, restores to the same place, and purges", async () => {
+ const del = await fsApi(lane1, "delete", { paths: ["docs/README.md", "src/lib"] });
+ assert.equal(del.status, 200);
+ assert.ok(!fs.existsSync(path.join(W1, "src/lib")));
+ const trash = (await request("GET", `/api/lanes/${lane1}/fs/trash`)).body;
+ assert.equal(trash.items.length, 2);
+ const lib = trash.items.find((i) => i.name === "lib");
+ assert.equal(lib.isDir, true);
+ const back = await fsApi(lane1, "trash/restore", { id: lib.id });
+ assert.equal(back.status, 200, JSON.stringify(back.body));
+ assert.equal(fs.readFileSync(path.join(W1, "src/lib/x.js"), "utf8"), "x\n");
+ assert.equal((await fsApi(lane1, "trash/purge", { id: "all" })).body.purged, 1);
+ assert.equal((await request("GET", `/api/lanes/${lane1}/fs/trash`)).body.items.length, 0);
+ assert.equal((await fsApi(lane1, "trash/restore", { id: "../../x" })).status, 400);
+ });
+
+ it("never deletes the workspace itself, .git, or a secret", async () => {
+ for (const p of [".", "", ".git", ".git/config", ".claude/settings.local.json"]) {
+ const r = await fsApi(lane1, "delete", { paths: [p] });
+ assert.ok(r.status >= 400, `${p} → ${r.status}`);
+ }
+ assert.ok(fs.existsSync(path.join(W1, ".git/config")));
+ });
+
+ it("uploads a stream into new sub-folders, with every conflict policy", async () => {
+ const up = (p, body, conflict) =>
+ request(
+ "POST",
+ `/api/lanes/${lane1}/fs/upload?path=${encodeURIComponent(p)}${conflict ? `&conflict=${conflict}` : ""}`,
+ Buffer.from(body),
+ { "Content-Type": "application/octet-stream" }
+ );
+ assert.equal((await up("site/css/app.css", "body{}")).status, 201);
+ assert.equal(fs.readFileSync(path.join(W1, "site/css/app.css"), "utf8"), "body{}");
+ const clash = await up("site/css/app.css", "new");
+ assert.equal(clash.status, 409);
+ assert.equal((await up("site/css/app.css", "new", "skip")).body.skipped, true);
+ assert.equal((await up("site/css/app.css", "kept", "keep")).body.path, "site/css/app (1).css");
+ await up("site/css/app.css", "replaced", "replace");
+ assert.equal(fs.readFileSync(path.join(W1, "site/css/app.css"), "utf8"), "replaced");
+ // The replaced one is in the trash, restorable.
+ assert.ok(
+ (await request("GET", `/api/lanes/${lane1}/fs/trash`)).body.items.some(
+ (i) => i.name === "app.css"
+ )
+ );
+ assert.equal((await up(".git/hooks/post-commit", "x")).status, 400);
+ assert.equal((await up("out/evil", "x")).status >= 400, true);
+ // A declared size over the cap is refused before a byte is stored.
+ const big = await new Promise((resolve) => {
+ const req = http.request(
+ `${base}/api/lanes/${lane1}/fs/upload?path=big.bin`,
+ { method: "POST", headers: { "Content-Length": String(600 * 1024 * 1024) } },
+ (r) => resolve(r.statusCode)
+ );
+ req.on("error", () => resolve("reset"));
+ req.write("x");
+ });
+ assert.equal(big, 413);
+ assert.ok(!fs.existsSync(path.join(W1, "big.bin")));
+ });
+
+ it("zips files and folders into one download that unzips to the same bytes", async () => {
+ const r = await request("GET", `/api/lanes/${lane1}/fs/zip?p=src&p=site/css/app.css`);
+ assert.equal(r.status, 200);
+ assert.equal(r.headers.get("content-type"), "application/zip");
+ const z = new AdmZip(r.buf);
+ const got = Object.fromEntries(
+ z
+ .getEntries()
+ .filter((e) => !e.isDirectory)
+ .map((e) => [e.entryName, e.getData().toString()])
+ );
+ assert.equal(got["src/lib/x.js"], "x\n");
+ assert.equal(got["app.css"], "replaced");
+ assert.ok(z.getEntries().some((e) => e.entryName === "src/lib/"));
+ // A secret is never in a zip.
+ const s = await request("GET", `/api/lanes/${lane1}/fs/zip?p=.claude`);
+ const zs = new AdmZip(s.buf);
+ assert.ok(!zs.getEntries().some((e) => e.entryName.includes("settings.local.json")));
+ });
+
+ it("refuses a cross-origin write", async () => {
+ const r = await request(
+ "POST",
+ `/api/lanes/${lane1}/fs/mkdir`,
+ { path: "x" },
+ { Origin: "https://evil.example" }
+ );
+ assert.equal(r.status, 403);
+ });
+});
+
+describe("File Explorer in a member's folder (S3, run-as stubbed)", () => {
+ const runAs = require("../lib/run-as");
+ const fx = require("../lib/file-explorer");
+ const M = path.join(ROOT, "users", "an", "proj");
+ const acct = {
+ name: "an",
+ login: "ah-an",
+ home: path.join(ROOT, "users", "an"),
+ uid: process.getuid(),
+ };
+ const saved = {};
+ let calls = [];
+ before(() => {
+ fs.mkdirSync(path.join(M, "d"), { recursive: true });
+ fs.writeFileSync(path.join(M, "f.txt"), "f");
+ for (const k of ["accountForPath", "writeAsAsync", "writeAsCanMove", "mkdirAs"])
+ saved[k] = runAs[k];
+ runAs.accountForPath = (p) => (String(p).startsWith(acct.home) ? acct : null);
+ runAs.writeAsCanMove = () => true;
+ runAs.mkdirAs = (_a, d) => calls.push(["mkdir", d]);
+ runAs.writeAsAsync = async (_a, file, args) => {
+ calls.push([file, ...args]);
+ };
+ });
+ after(() => Object.assign(runAs, saved));
+
+ it("moves and copies through write-as as the member, never with fs", async () => {
+ calls = [];
+ await fx.move(M, ["f.txt"], "d");
+ await fx.copy(M, ["d"], "", {});
+ assert.deepEqual(calls[0], [path.join(M, "f.txt"), "move", path.join(M, "d", "f.txt")]);
+ assert.deepEqual(calls[1], [path.join(M, "d"), "copy", path.join(M, "d (1)")]);
+ assert.ok(fs.existsSync(path.join(M, "f.txt")), "the host itself moved nothing");
+ });
+
+ it("says the helper needs updating instead of failing obscurely", async () => {
+ runAs.writeAsCanMove = () => false;
+ try {
+ await assert.rejects(fx.rename(M, "f.txt", "g.txt"), { code: "EUPGRADE" });
+ } finally {
+ runAs.writeAsCanMove = () => true;
+ }
+ });
+});
diff --git a/server/__tests__/home-files.test.js b/server/__tests__/home-files.test.js
new file mode 100644
index 00000000..4a92560c
--- /dev/null
+++ b/server/__tests__/home-files.test.js
@@ -0,0 +1,206 @@
+/**
+ * @file Tests for the File Explorer on a user's HOME (`/api/homes`,
+ * server/lib/homes.js + file-explorer.js `inHome`): the caller's own home is
+ * listed and writable, every dot entry at its top stays hidden and refused
+ * (also through a symlink, a download or a zip) while a nested dotfile is
+ * fine, delete/restore work with no workspace, a copy goes home ↔ workspace
+ * under each side's own rule — and in server mode a developer never reaches
+ * another user's home while an admin gets it read-only.
+ * @author Nguyễn Ngọc Trí Vĩ
+ */
+
+const { describe, it, before, after } = require("node:test");
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const os = require("node:os");
+const path = require("node:path");
+const http = require("node:http");
+
+const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "home-files-"));
+const HOME = path.join(ROOT, "home");
+fs.mkdirSync(HOME);
+process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
+process.env.CLAUDE_HOME = path.join(HOME, ".claude");
+process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "data");
+process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
+process.env.DASHBOARD_LIVENESS_PROBE = "0";
+process.env.HOME = HOME;
+
+const { createApp } = require("../index");
+const AdmZip = require("adm-zip");
+
+let app;
+let base;
+let lane;
+const WS = path.join(HOME, "projects", "app");
+
+function request(method, p, body) {
+ return fetch(`${base}${p}`, {
+ method,
+ headers: { "Content-Type": "application/json" },
+ body: body === undefined ? undefined : JSON.stringify(body),
+ }).then(async (r) => {
+ const buf = Buffer.from(await r.arrayBuffer());
+ let json = null;
+ try {
+ json = JSON.parse(buf.toString("utf8"));
+ } catch {
+ /* binary */
+ }
+ return { status: r.status, body: json, buf };
+ });
+}
+const H = "/api/homes/me/fs";
+const ls = async (p = "") => (await request("GET", `${H}/list?path=${encodeURIComponent(p)}`)).body;
+
+before(async () => {
+ fs.mkdirSync(path.join(HOME, ".ssh"));
+ fs.writeFileSync(path.join(HOME, ".ssh", "id_ed25519"), "PRIVATE");
+ fs.writeFileSync(path.join(HOME, ".bashrc"), "alias x=y\n");
+ fs.symlinkSync(".ssh", path.join(HOME, "keys"));
+ fs.mkdirSync(path.join(HOME, "Documents"));
+ fs.writeFileSync(path.join(HOME, "Documents", "notes.txt"), "hello\n");
+ fs.mkdirSync(WS, { recursive: true });
+ fs.writeFileSync(path.join(WS, ".env.example"), "A=1\n");
+ app = http.createServer(createApp());
+ await new Promise((r) => app.listen(0, "127.0.0.1", r));
+ base = `http://127.0.0.1:${app.address().port}`;
+ lane = (await request("POST", "/api/lanes/ensure", { cwd: WS, exact: true })).body.lane.id;
+});
+after(async () => {
+ await new Promise((r) => app.close(r));
+});
+
+describe("File Explorer on the home", () => {
+ it("offers the caller's own home", async () => {
+ const r = await request("GET", "/api/homes");
+ assert.deepEqual(
+ r.body.homes.map((h) => [h.id, h.readOnly]),
+ [["me", false]]
+ );
+ });
+
+ it("lists the home with every top-level dot entry hidden", async () => {
+ const l = await ls();
+ const names = l.entries.map((e) => e.name);
+ assert.ok(names.includes("Documents") && names.includes("projects"));
+ assert.ok(!names.some((n) => n.startsWith(".")), names.join(","));
+ for (const p of [".ssh", ".bashrc", "keys", ".claude"]) {
+ const r = await request("GET", `${H}/list?path=${encodeURIComponent(p)}`);
+ assert.ok([403, 404].includes(r.status), `${p} → ${r.status}`);
+ }
+ });
+
+ it("never serves a top-level dotfile — not by download, a symlink or a zip", async () => {
+ for (const p of [".bashrc", ".ssh/id_ed25519", "keys/id_ed25519"]) {
+ const r = await request("GET", `${H}/download?path=${encodeURIComponent(p)}`);
+ assert.ok(r.status >= 400, `${p} → ${r.status}`);
+ assert.ok(!r.buf.toString().includes("PRIVATE"));
+ }
+ const z = await request("GET", `${H}/zip?p=Documents`);
+ assert.equal(z.status, 200);
+ assert.deepEqual(
+ new AdmZip(z.buf)
+ .getEntries()
+ .map((e) => e.entryName)
+ .sort(),
+ ["Documents/", "Documents/notes.txt"]
+ );
+ const zDot = await request("GET", `${H}/zip?p=.ssh`);
+ assert.ok(zDot.status >= 400);
+ });
+
+ it("downloads an ordinary file of the home", async () => {
+ const r = await request("GET", `${H}/download?path=Documents/notes.txt`);
+ assert.equal(r.status, 200);
+ assert.equal(r.buf.toString(), "hello\n");
+ });
+
+ it("makes and trashes things in the home, but never a top-level dot entry", async () => {
+ assert.equal((await request("POST", `${H}/mkdir`, { path: "Downloads" })).status, 201);
+ assert.equal(
+ (await request("POST", `${H}/newfile`, { path: "Downloads/.hidden-ok" })).status,
+ 201
+ );
+ assert.equal((await request("POST", `${H}/newfile`, { path: ".profile2" })).status, 403);
+ assert.equal((await request("POST", `${H}/mkdir`, { path: ".ssh/x" })).status, 403);
+ assert.equal(
+ (await request("POST", `${H}/rename`, { path: "Downloads", name: ".Downloads" })).status,
+ 403
+ );
+ const del = await request("POST", `${H}/delete`, { paths: ["Downloads"] });
+ assert.equal(del.status, 200);
+ const trash = (await request("GET", `${H}/trash`)).body.items;
+ assert.equal(trash[0].name, "Downloads");
+ assert.equal(trash[0].laneId, null);
+ assert.equal((await request("POST", `${H}/trash/restore`, { id: trash[0].id })).status, 200);
+ assert.ok(fs.existsSync(path.join(HOME, "Downloads", ".hidden-ok")));
+ });
+
+ it("copies home → workspace and workspace → home, each under its own rule", async () => {
+ const toWs = await request("POST", `/api/lanes/${lane}/fs/copy`, {
+ paths: ["Documents/notes.txt"],
+ dest: "",
+ fromHome: "me",
+ });
+ assert.equal(toWs.status, 200, JSON.stringify(toWs.body));
+ assert.ok(fs.existsSync(path.join(WS, "notes.txt")));
+ // A workspace's own dot file is a fine source…
+ const toHome = await request("POST", `${H}/copy`, {
+ paths: [".env.example"],
+ dest: "Documents",
+ fromLane: lane,
+ });
+ assert.equal(toHome.status, 200, JSON.stringify(toHome.body));
+ assert.ok(fs.existsSync(path.join(HOME, "Documents", ".env.example")));
+ // …a home's top-level one is not.
+ const leak = await request("POST", `/api/lanes/${lane}/fs/copy`, {
+ paths: ["keys/id_ed25519"],
+ dest: "",
+ fromHome: "me",
+ });
+ assert.equal(leak.status, 403);
+ assert.ok(!fs.existsSync(path.join(WS, "id_ed25519")));
+ });
+
+ it("refuses an unknown home", async () => {
+ assert.equal((await request("GET", "/api/homes/someone/fs/list")).status, 404);
+ });
+});
+
+describe("homes in server mode", () => {
+ const homes = require("../lib/homes");
+ const USERS = path.join(ROOT, "users");
+ before(() => {
+ fs.mkdirSync(path.join(USERS, "bob"), { recursive: true });
+ fs.mkdirSync(path.join(USERS, "ann"), { recursive: true });
+ process.env.AGENTHUB_MODE = "server";
+ process.env.AGENTHUB_USERS_ROOT = USERS;
+ const { db } = require("../db");
+ db.prepare(
+ "INSERT INTO users (username, password_hash, role) VALUES ('bob', 'x', 'developer'), ('ann', 'x', 'admin')"
+ ).run();
+ });
+ after(() => {
+ delete process.env.AGENTHUB_MODE;
+ delete process.env.AGENTHUB_USERS_ROOT;
+ });
+
+ it("gives a developer only their own folder as home", () => {
+ const bob = { id: 1, username: "bob", role: "developer" };
+ assert.equal(homes.resolveHome({ user: bob }, "me").dir, path.join(USERS, "bob"));
+ assert.throws(() => homes.resolveHome({ user: bob }, "ann"), { code: "ENOHOME" });
+ assert.deepEqual(
+ homes.listHomes({ user: bob }).map((h) => h.id),
+ ["me"]
+ );
+ });
+
+ it("lets an admin open another user's home read-only", () => {
+ const ann = { id: 2, username: "ann", role: "admin" };
+ const h = homes.resolveHome({ user: ann }, "bob");
+ assert.equal(h.readOnly, true);
+ assert.equal(h.dir, path.join(USERS, "bob"));
+ assert.ok(homes.listHomes({ user: ann }).some((x) => x.id === "bob" && x.readOnly));
+ });
+});
diff --git a/server/__tests__/s3-fail-closed.test.js b/server/__tests__/s3-fail-closed.test.js
new file mode 100644
index 00000000..08df6a5f
--- /dev/null
+++ b/server/__tests__/s3-fail-closed.test.js
@@ -0,0 +1,84 @@
+/**
+ * @file S3 fails closed: a server that started with S3 keeps its
+ * server.conf when the file later fails to read (it never silently becomes
+ * "no S3", which would run a member's pane as the host account), and a tmux
+ * call whose account cannot be told under S3 fails instead of going to the
+ * host's own tmux server.
+ * @author Nguyễn Ngọc Trí Vĩ
+ */
+
+const { describe, it, after } = require("node:test");
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const os = require("node:os");
+const path = require("node:path");
+
+const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "s3-fail-closed-"));
+const CONF = path.join(TMP, "server.conf");
+process.env.AGENTHUB_SERVER_CONF = CONF;
+process.env.DASHBOARD_DB_PATH = path.join(TMP, "t.db");
+
+const linux = require("../lib/linux-accounts");
+const tmux = require("../lib/tmux");
+
+const writeConf = () =>
+ fs.writeFileSync(CONF, `HOST_USER=h\nHOST_UID=1000\nUSERS_ROOT=${path.join(TMP, "users")}\n`);
+const later = (ms) => {
+ const real = Date.now;
+ const t = real() + ms;
+ Date.now = () => t;
+ return () => (Date.now = real);
+};
+
+after(() => {
+ delete process.env.AGENTHUB_MODE;
+ linux.__resetConfCache();
+ tmux.__reset();
+});
+
+describe("S3 fails closed", () => {
+ it("keeps the last good server.conf in server mode when the file stops reading", () => {
+ process.env.AGENTHUB_MODE = "server";
+ linux.__resetConfCache();
+ writeConf();
+ assert.equal(linux.s3Enabled(), true);
+ fs.rmSync(CONF);
+ const restore = later(10_000); // past the 5 s cache
+ try {
+ assert.equal(linux.s3Enabled(), true, "still S3: never fall back to the host");
+ assert.equal(linux.serverConf().usersRoot, path.join(TMP, "users"));
+ } finally {
+ restore();
+ }
+ });
+
+ it("outside server mode a missing file is simply no S3", () => {
+ delete process.env.AGENTHUB_MODE;
+ linux.__resetConfCache();
+ writeConf();
+ linux.serverConf();
+ fs.rmSync(CONF);
+ const restore = later(10_000);
+ try {
+ assert.equal(linux.serverConf(), null);
+ } finally {
+ restore();
+ }
+ });
+
+ it("a tmux call whose account cannot be told under S3 fails, never reaching the host's tmux", () => {
+ process.env.AGENTHUB_MODE = "server";
+ linux.__resetConfCache();
+ writeConf();
+ const calls = [];
+ tmux.__setExecImpl((args) => {
+ calls.push(args);
+ return "";
+ });
+ tmux.__setAccountImpl(() => {
+ throw Object.assign(new Error("database is locked"), { code: "SQLITE_BUSY" });
+ });
+ assert.throws(() => tmux.sendText("agenthub-lane-7", "ls"), { code: "SQLITE_BUSY" });
+ assert.deepEqual(calls, [], "nothing ran on the host's tmux server");
+ });
+});
diff --git a/server/__tests__/server-mode-proxy-gateway.test.js b/server/__tests__/server-mode-proxy-gateway.test.js
index 29f55dd8..29766444 100644
--- a/server/__tests__/server-mode-proxy-gateway.test.js
+++ b/server/__tests__/server-mode-proxy-gateway.test.js
@@ -202,6 +202,24 @@ describe("the proxy gateway", () => {
assert.equal(res.status, 403);
});
+ it("marks what it relays, and the dashboard refuses anything so marked", async () => {
+ seen = [];
+ await gw(`${profileName}/v1/messages`, { headers: { "x-api-key": LIVE_TOKEN }, body: "{}" });
+ assert.equal(seen[0].headers["x-agenthub-via-gateway"], "1");
+ // A profile pointed back at the dashboard itself: what arrives carries
+ // the mark, so no route answers it — the hooks least of all.
+ const self = `http://127.0.0.1:${server.address().port}`;
+ for (const p of ["/api/hooks/event", "/api/lanes", "/api/health"]) {
+ const r = await fetch(`${self}${p}`, {
+ method: "POST",
+ headers: { "content-type": "application/json", "x-agenthub-via-gateway": "1" },
+ body: "{}",
+ });
+ assert.equal(r.status, 403, p);
+ assert.equal((await r.json()).error.code, "EGATEWAY");
+ }
+ });
+
it("is not there in local mode", async () => {
delete process.env.AGENTHUB_MODE;
try {
diff --git a/server/__tests__/server-s3-handoff.test.js b/server/__tests__/server-s3-handoff.test.js
index 2a623dad..a6cff003 100644
--- a/server/__tests__/server-s3-handoff.test.js
+++ b/server/__tests__/server-s3-handoff.test.js
@@ -98,6 +98,16 @@ describe("a member pane's files live in the member's handoff dir", () => {
assert.equal(acct?.login, "ah-mia");
});
+ it("refuses a NUL inside a pane's argv or env: it would split the launch file", () => {
+ assert.throws(
+ () => runAs.launchArgv(acct, {}, ["claude", "hi\0--dangerously-skip-permissions"]),
+ {
+ code: "EBADARG",
+ }
+ );
+ assert.throws(() => runAs.launchArgv(acct, { A: "x\0y" }, ["claude"]), { code: "EBADARG" });
+ });
+
it("copies the context files and writes the prompt there, readable", () => {
const x = launchExtras(laneId, "claude");
const ctx = path.join(HANDOFF, `context-${laneId}`);
diff --git a/server/__tests__/server-s3-review5.test.js b/server/__tests__/server-s3-review5.test.js
index cbddfe6b..a4256ec4 100644
--- a/server/__tests__/server-s3-review5.test.js
+++ b/server/__tests__/server-s3-review5.test.js
@@ -572,6 +572,40 @@ describe("who a pane runs as", () => {
}
});
+ it("a demoted admin can still READ their host-folder workspace but no longer WRITE to it", async () => {
+ fs.mkdirSync(path.join(HOST, "w"), { recursive: true });
+ const lane = addLane(path.join(HOST, "w"), "boss");
+ auth.setRole("boss", "developer");
+ try {
+ assert.equal((await call("boss", "GET", `/api/lanes/${lane}`)).status, 200);
+ for (const [method, url, body] of [
+ ["POST", `/api/lanes/${lane}/up`, {}],
+ ["POST", `/api/lanes/${lane}/git/commit`, { message: "x" }],
+ ["PUT", `/api/lanes/${lane}/git/file`, { path: "a.txt", content: "x" }],
+ ["PATCH", `/api/workspaces/${lane}`, { title: "t" }],
+ ]) {
+ const r = await call("boss", method, url, body);
+ assert.equal(r.status, 409, `${method} ${url} ${JSON.stringify(r.body)}`);
+ assert.equal(r.body.error.code, "EOWNERFOLDER");
+ }
+ } finally {
+ auth.setRole("boss", "admin");
+ }
+ // Back to admin: the same write passes the guard again.
+ const ok = await call("boss", "PATCH", `/api/lanes/${lane}`, { title: "back" });
+ assert.equal(ok.status, 200, JSON.stringify(ok.body));
+ });
+
+ it("a member's own workspace writes are untouched by that check", async () => {
+ const r = await call("mia", "GET", "/api/lanes");
+ const mine = (r.body.lanes || []).find((l) =>
+ String(l.cwd).startsWith(path.join(USERS, "mia"))
+ );
+ if (!mine) return; // no member workspace in this fixture
+ const w = await call("mia", "PATCH", `/api/lanes/${mine.id}`, { title: "still mine" });
+ assert.notEqual(w.status, 409, JSON.stringify(w.body));
+ });
+
it("a pane's cwd must be its workspace's account's (POST /api/run names the cwd)", async () => {
const mine = addLane(path.join(MIA, "own"), "mia");
assert.throws(
diff --git a/server/index.js b/server/index.js
index 2e1c6264..4cffab8b 100644
--- a/server/index.js
+++ b/server/index.js
@@ -109,6 +109,7 @@ const codexConfigRouter = require("./routes/codex-config");
const aiProxiesRouter = require("./routes/ai-proxies");
const gitCredentialsRouter = require("./routes/git-credentials");
const gitIdentityRouter = require("./routes/git-identity");
+const homesRouter = require("./routes/homes");
const aiToolsRouter = require("./routes/ai-tools");
const runRouter = require("./routes/run");
const alertsRouter = require("./routes/alerts");
@@ -151,6 +152,17 @@ function createApp() {
// Server mode's AI Proxy key gateway (server/lib/proxy-gateway.js): before
// the body parser (it streams the raw body) and the token/login gates (a
// pane authenticates with its own pane token, loopback only).
+ // A request the gateway itself sent (a profile whose base points back at
+ // this dashboard): never served — it would arrive from our own uid, which
+ // hook auth trusts, on behalf of whoever started the pane.
+ const { GATEWAY_MARK } = require("./lib/proxy-gateway");
+ app.use((req, res, next) =>
+ req.headers[GATEWAY_MARK]
+ ? res.status(403).json({
+ error: { code: "EGATEWAY", message: "a request relayed by the AI Proxy gateway" },
+ })
+ : next()
+ );
app.use("/api/proxy-gw", require("./lib/proxy-gateway").proxyGateway);
app.use(express.json({ limit: "1mb" }));
app.use("/api", tokenGuard);
@@ -162,6 +174,8 @@ function createApp() {
app.use("/api/auth", authRouter);
// Before /api/me: the user's own SSH key and HTTPS logins (git-identity.js).
app.use("/api/me/git", gitIdentityRouter);
+ // The File Explorer on a user's home (routes/homes.js).
+ app.use("/api/homes", homesRouter);
app.use("/api/me", meRouter);
app.use("/api/accounts", accountsRouter);
app.use("/api/users", usersRouter);
diff --git a/server/lib/audit.js b/server/lib/audit.js
index 1c77cd89..5ceac414 100644
--- a/server/lib/audit.js
+++ b/server/lib/audit.js
@@ -6,7 +6,8 @@
* it, whose it was and which path. `auditGuard` runs on `/api` after the
* scope guard; it never blocks a request, and it is a no-op in local mode and
* for a member (who can only read their own). An admin's account change on
- * the Users page is written here too (`recordAccountChange`).
+ * the Users page is written here too (`recordAccountChange`), and so is an
+ * admin's look into another user's home (`recordHomeRead`, File Explorer).
* @author Nguyễn Ngọc Trí Vĩ
*/
@@ -68,4 +69,15 @@ function recordAccountChange(actorId, targetId, method, action) {
).run(actorId, targetId, method, String(action).slice(0, 500));
}
-module.exports = { auditGuard, listAudit, recordAccountChange };
+/** An admin opening another user's home in the File Explorer (read-only). */
+function recordHomeRead(actorId, ownerId, method, what) {
+ try {
+ sql(
+ "INSERT INTO audit_log (user_id, owner_id, session_id, method, path) VALUES (?, ?, NULL, ?, ?)"
+ ).run(actorId, ownerId, method, String(what).slice(0, 500));
+ } catch (err) {
+ console.warn("[audit] could not record:", err.message);
+ }
+}
+
+module.exports = { auditGuard, listAudit, recordAccountChange, recordHomeRead };
diff --git a/server/lib/file-explorer.js b/server/lib/file-explorer.js
new file mode 100644
index 00000000..e1bfdfed
--- /dev/null
+++ b/server/lib/file-explorer.js
@@ -0,0 +1,811 @@
+/**
+ * @file The workspace File Explorer (`/api/lanes/:id/fs/*`): list a folder,
+ * make a folder or an empty file, rename, move and copy (within a workspace
+ * or into another of the same account), delete into a per-account TRASH that
+ * can be restored (kept TRASH_DAYS), upload a file streamed from the request
+ * into any (new) sub-folder, and download files or folders as one streamed zip.
+ *
+ * Every path is the read path's own (git-diff.js): `lanePath` (lexical, no
+ * `..`), the PARENT resolved by `resolveInLane` (realpath-confined, so no
+ * symlink walks out) and the last segment kept as a name — so an operation on
+ * a symlink acts on the link itself, never on what it points to — and no
+ * secret (`settings.local.json`, a member's dot-dirs) by name or by what it
+ * resolves to. Nothing is created, moved or deleted in or under `.git` (a
+ * hook there runs the next time the dashboard runs git), and the workspace
+ * folder itself is never moved or deleted.
+ *
+ * Writes run as the folder's ACCOUNT (run-as.js): a member's through
+ * `write-as` (sudo, as them — the host never writes in their home), the
+ * host's directly. A move/copy/trash in a member's folder needs write-as's
+ * `move`/`copy` (`EUPGRADE` until `sudo agenthub server install-system` has
+ * installed it). A copy or move never crosses accounts (`EFOREIGN`).
+ *
+ * A name that is taken is never silently replaced: `conflict` decides —
+ * `fail` (default, 409 EEXISTS with the names), `skip`, `keep` (a free
+ * "name (1).ext") or `replace` (the old one goes to the trash first).
+ *
+ * A user's HOME is browsed through the same functions, run inside `inHome`:
+ * there every dot entry at the top of the home (`.ssh`, `.claude`, `.config`,
+ * `.agenthub`…, where logins and pane tokens live) is hidden from listings and
+ * refused for every read and write — also when reached through a symlink —
+ * the same rule `inMemberDotDir` keeps for a member's home everywhere.
+ * @author Nguyễn Ngọc Trí Vĩ
+ */
+
+const crypto = require("node:crypto");
+const fs = require("node:fs");
+const path = require("node:path");
+const zlib = require("node:zlib");
+const { Readable, Transform } = require("node:stream");
+const { AsyncLocalStorage } = require("node:async_hooks");
+const { pipeline } = require("node:stream/promises");
+
+const { isSecretOnDisk, lanePath, laneRoot, resolveInLane } = require("./git-diff");
+const runAs = require("./run-as");
+const { getDataDir } = require("./claude-home");
+
+/** One uploaded file, streamed (the client keeps a whole batch to this too). */
+const UPLOAD_MAX_BYTES = 500 * 1024 * 1024;
+/** One zip download, uncompressed — and below ZIP64's 4 GiB offsets. */
+const ZIP_MAX_BYTES = 2 * 1024 * 1024 * 1024;
+const ZIP_MAX_FILES = 50_000;
+const LIST_MAX = 5000;
+const TRASH_DAYS = 7;
+const CONFLICTS = new Set(["fail", "skip", "keep", "replace"]);
+
+const bad = (code, message, extra = {}) => Object.assign(new Error(message), { code, ...extra });
+
+// Set while a request works on a user's home (see the file header).
+const homeCtx = new AsyncLocalStorage();
+/** Run `fn` with `dir` treated as a home: its top-level dot entries are off limits. */
+const inHome = (fn) => homeCtx.run(true, fn);
+/** Whether `abs` is, or lies under, a dot entry at the top of a home root `root`. */
+function topDot(root, abs) {
+ if (homeCtx.getStore() !== true) return false;
+ const rel = path.relative(root, abs);
+ return Boolean(rel) && !escapes(root, abs) && rel.split(path.sep)[0].startsWith(".");
+}
+const hidden = () => bad("ESECRET", "hidden files at the top of a home are not reachable here");
+const gitInside = (rel) => rel.split("/").includes(".git");
+const escapes = (base, target) => {
+ const r = path.relative(base, target);
+ return r === ".." || r.startsWith(`..${path.sep}`) || path.isAbsolute(r);
+};
+
+/** One plain path segment a user may name. */
+function validName(n) {
+ const name = typeof n === "string" ? n.trim() : "";
+ if (
+ !name ||
+ name === "." ||
+ name === ".." ||
+ /[/\\\0]/.test(name) ||
+ Buffer.byteLength(name) > 255
+ ) {
+ throw bad("EBADNAME", "a name without '/' or '\\', at most 255 bytes");
+ }
+ return name;
+}
+
+function conflictOf(v) {
+ return CONFLICTS.has(v) ? v : "fail";
+}
+
+/** A workspace folder by relative path ("" = the workspace root). */
+async function folder(dir, rel) {
+ const root = await laneRoot(dir);
+ if (!rel) return { rel: "", abs: root, root };
+ const p = lanePath(rel);
+ const abs = await resolveInLane(dir, p);
+ if (topDot(root, abs)) throw hidden();
+ const st = await fs.promises.stat(abs).catch(() => null);
+ if (!st?.isDirectory()) throw bad("ENOFILE", "no such folder in the workspace");
+ return { rel: p, abs, root };
+}
+
+/**
+ * An entry by relative path, existing or not: its parent resolved and
+ * confined, its name kept (a symlink is the link). `st` is its lstat or null.
+ */
+async function locate(dir, rel) {
+ const p = lanePath(rel);
+ if (await isSecretOnDisk(dir, p)) throw bad("ESECRET", "that path holds secrets");
+ const parent = path.posix.dirname(p);
+ const name = path.posix.basename(p);
+ const par = await folder(dir, parent === "." ? "" : parent);
+ const abs = path.join(par.abs, name);
+ if (escapes(par.root, abs)) throw bad("EBADPATH", "path escapes the workspace directory");
+ if (topDot(par.root, abs)) throw hidden();
+ if (await isSecretOnDisk(par.root, path.relative(par.root, abs))) {
+ throw bad("ESECRET", "that path holds secrets");
+ }
+ const st = await fs.promises.lstat(abs).catch(() => null);
+ return { rel: p, name, abs, parent: par, root: par.root, st };
+}
+
+async function existing(dir, rel) {
+ const loc = await locate(dir, rel);
+ if (!loc.st) throw bad("ENOFILE", `no such file or folder: ${loc.rel}`);
+ return loc;
+}
+
+function writable(loc) {
+ if (gitInside(loc.rel)) throw bad("EBADPATH", "nothing in .git is changed from here");
+}
+
+/** The account a write at `abs` runs as (null = the host); EMEMBERGONE throws. */
+const accountAt = (abs) => runAs.accountForPath(abs);
+
+function needMove(acct) {
+ if (acct && !runAs.writeAsCanMove()) {
+ throw bad(
+ "EUPGRADE",
+ "moving or copying in a member's folder needs the updated helper: run `sudo agenthub server install-system` once"
+ );
+ }
+}
+
+/** A name in `parentAbs` not taken yet: "a (1).txt", "a (2).txt"… */
+async function freeName(parentAbs, name) {
+ const ext = name.startsWith(".") ? "" : path.extname(name);
+ const stem = ext ? name.slice(0, -ext.length) : name;
+ for (let i = 1; i < 10_000; i++) {
+ const n = `${stem} (${i})${ext}`;
+ if (!(await fs.promises.lstat(path.join(parentAbs, n)).catch(() => null))) return n;
+ }
+ throw bad("EEXISTS", `no free name for ${name}`);
+}
+
+// ── Reading ────────────────────────────────────────────────────────────────
+
+/** A folder's entries, folders first: `{name, type, size, mtime}`. */
+async function list(dir, rel = "") {
+ const f = await folder(dir, rel);
+ const names = (await fs.promises.readdir(f.abs)).sort((a, b) => a.localeCompare(b));
+ const entries = [];
+ for (const name of names.slice(0, LIST_MAX)) {
+ const relChild = f.rel ? `${f.rel}/${name}` : name;
+ if (topDot(f.root, path.join(f.abs, name))) continue;
+ if (await isSecretOnDisk(f.root, path.relative(f.root, path.join(f.abs, name)))) continue;
+ const st = await fs.promises.lstat(path.join(f.abs, name)).catch(() => null);
+ if (!st) continue;
+ let type = st.isDirectory()
+ ? "dir"
+ : st.isFile()
+ ? "file"
+ : st.isSymbolicLink()
+ ? "link"
+ : "other";
+ if (type === "link") {
+ // Shown as what it points to, but only while that stays inside.
+ const real = await fs.promises.realpath(path.join(f.abs, name)).catch(() => null);
+ const tst =
+ real && !escapes(f.root, real) ? await fs.promises.stat(real).catch(() => null) : null;
+ if (tst?.isDirectory()) type = "linkdir";
+ }
+ entries.push({
+ name,
+ path: relChild,
+ type,
+ size: st.isFile() ? st.size : null,
+ mtime: st.mtimeMs,
+ });
+ }
+ const rank = (e) => (e.type === "dir" || e.type === "linkdir" ? 0 : 1);
+ entries.sort((a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name));
+ return {
+ path: f.rel,
+ parent: f.rel ? (path.posix.dirname(f.rel) === "." ? "" : path.posix.dirname(f.rel)) : null,
+ entries,
+ truncated: names.length > LIST_MAX,
+ };
+}
+
+// ── Making ─────────────────────────────────────────────────────────────────
+
+async function mkdir(dir, rel) {
+ const loc = await locate(dir, rel);
+ validName(loc.name);
+ writable(loc);
+ if (loc.st) throw bad("EEXISTS", `${loc.rel} already exists`, { names: [loc.name] });
+ const acct = accountAt(loc.abs);
+ if (acct) runAs.mkdirAs(acct, loc.abs);
+ else await fs.promises.mkdir(loc.abs);
+ return { path: loc.rel };
+}
+
+async function newFile(dir, rel) {
+ const loc = await locate(dir, rel);
+ validName(loc.name);
+ writable(loc);
+ if (loc.st) throw bad("EEXISTS", `${loc.rel} already exists`, { names: [loc.name] });
+ const acct = accountAt(loc.abs);
+ if (acct) await runAs.writeAsAsync(acct, loc.abs, ["replace"], { input: "" });
+ else await fs.promises.writeFile(loc.abs, "", { flag: "wx" });
+ return { path: loc.rel };
+}
+
+/** Make every missing folder of `relDir` (each one checked like `mkdir`). */
+async function ensureFolders(dir, relDir) {
+ if (!relDir) return;
+ const parts = lanePath(relDir).split("/");
+ for (let i = 1; i <= parts.length; i++) {
+ const sub = parts.slice(0, i).join("/");
+ const loc = await locate(dir, sub);
+ if (loc.st) {
+ if (!loc.st.isDirectory()) throw bad("EEXISTS", `${sub} is a file, not a folder`);
+ continue;
+ }
+ validName(loc.name);
+ writable(loc);
+ const acct = accountAt(loc.abs);
+ if (acct) runAs.mkdirAs(acct, loc.abs);
+ else await fs.promises.mkdir(loc.abs);
+ }
+}
+
+// ── Moving, copying ────────────────────────────────────────────────────────
+
+/** Host-side move: a rename, or copy + remove across filesystems. */
+async function hostMove(from, to) {
+ try {
+ await fs.promises.rename(from, to);
+ } catch (e) {
+ if (e.code !== "EXDEV") throw e;
+ await fs.promises.cp(from, to, {
+ recursive: true,
+ verbatimSymlinks: true,
+ errorOnExist: true,
+ force: false,
+ preserveTimestamps: true,
+ });
+ await fs.promises.rm(from, { recursive: true, force: true });
+ }
+}
+
+async function moveAs(acct, from, to) {
+ if (acct) {
+ needMove(acct);
+ await runAs.writeAsAsync(acct, from, ["move", to]);
+ } else {
+ await hostMove(from, to);
+ }
+}
+
+/**
+ * The targets of a move/copy of `srcs` into `dest`, conflicts settled by
+ * `conflict` — or EEXISTS (409) naming every taken name when it is `fail`,
+ * before anything is done.
+ */
+async function plan(srcs, dest, conflict) {
+ const out = [];
+ const taken = [];
+ for (const s of srcs) {
+ let name = s.name;
+ let target = path.join(dest.abs, name);
+ const st = await fs.promises.lstat(target).catch(() => null);
+ // Onto itself is handled below (a copy keeps both); only INTO itself is refused.
+ if (s.st.isDirectory() && target.startsWith(`${s.abs}${path.sep}`)) {
+ throw bad("EBADPATH", `${s.rel} cannot go inside itself`);
+ }
+ if (target === s.abs) {
+ // Onto itself: a copy keeps both, a move does nothing.
+ out.push({ src: s, target, name, self: true });
+ continue;
+ }
+ if (st) {
+ if (conflict === "fail") {
+ taken.push(name);
+ continue;
+ }
+ if (conflict === "skip") continue;
+ if (conflict === "keep") {
+ name = await freeName(dest.abs, name);
+ target = path.join(dest.abs, name);
+ } else {
+ out.push({ src: s, target, name, replace: true });
+ continue;
+ }
+ }
+ out.push({ src: s, target, name });
+ }
+ if (taken.length) {
+ throw bad("EEXISTS", `already in the destination: ${taken.join(", ")}`, { names: taken });
+ }
+ return out;
+}
+
+function sameAccount(...paths) {
+ if (!runAs.sameOwner(...paths)) {
+ throw bad("EFOREIGN", "files move or copy only between folders of one account");
+ }
+}
+
+async function move(dir, rels, destRel, { conflict, trash } = {}) {
+ const dest = await folder(dir, destRel);
+ if (gitInside(dest.rel)) throw bad("EBADPATH", "nothing is moved into .git");
+ const srcs = [];
+ for (const r of rels) {
+ const s = await existing(dir, r);
+ writable(s);
+ srcs.push(s);
+ }
+ sameAccount(dest.abs, ...srcs.map((s) => s.abs));
+ const steps = await plan(srcs, dest, conflictOf(conflict));
+ const done = [];
+ for (const st of steps) {
+ if (st.self) continue;
+ const acct = accountAt(st.target);
+ if (st.replace) await trash(path.relative(dest.root, st.target).split(path.sep).join("/"));
+ await moveAs(acct, st.src.abs, st.target);
+ done.push(path.relative(dest.root, st.target).split(path.sep).join("/"));
+ }
+ return { moved: done };
+}
+
+async function rename(dir, rel, newName) {
+ const s = await existing(dir, rel);
+ writable(s);
+ const name = validName(newName);
+ if (name === s.name) return { path: s.rel };
+ const to = await locate(dir, s.rel.includes("/") ? `${path.posix.dirname(s.rel)}/${name}` : name);
+ writable(to);
+ if (to.st) throw bad("EEXISTS", `${to.rel} already exists`, { names: [name] });
+ await moveAs(accountAt(to.abs), s.abs, to.abs);
+ return { path: to.rel };
+}
+
+/** Copy `rels` of workspace `srcDir` into `destRel` of workspace `dir`. */
+async function copy(dir, rels, destRel, { srcDir = dir, srcHome, conflict, trash } = {}) {
+ const dest = await folder(dir, destRel);
+ if (gitInside(dest.rel)) throw bad("EBADPATH", "nothing is copied into .git");
+ // The sources are checked under THEIR place's rule (a home's dot entries
+ // stay out of reach; a workspace's `.github` does not), the destination
+ // under this call's.
+ const destHome = homeCtx.getStore() === true;
+ const fromHome = srcHome ?? (srcDir === dir ? destHome : false);
+ const srcs = await homeCtx.run(fromHome, async () => {
+ const out = [];
+ for (const r of rels) {
+ const s = await existing(srcDir, r);
+ const real = await fs.promises.realpath(s.abs).catch(() => s.abs);
+ if (topDot(s.root, real)) throw hidden();
+ out.push(s);
+ }
+ return out;
+ });
+ sameAccount(dest.abs, ...srcs.map((s) => s.abs));
+ let steps = await plan(srcs, dest, conflictOf(conflict));
+ // A copy onto itself keeps both, like a file manager's paste in place.
+ steps = await Promise.all(
+ steps.map(async (st) =>
+ st.self ? { ...st, name: await freeName(dest.abs, st.name), self: false } : st
+ )
+ );
+ const done = [];
+ for (const st of steps) {
+ const target = path.join(dest.abs, st.name);
+ const acct = accountAt(target);
+ if (st.replace) await trash(path.relative(dest.root, target).split(path.sep).join("/"));
+ if (acct) {
+ needMove(acct);
+ await runAs.writeAsAsync(acct, st.src.abs, ["copy", target]);
+ } else {
+ await fs.promises.cp(st.src.abs, target, {
+ recursive: true,
+ verbatimSymlinks: true,
+ errorOnExist: true,
+ force: false,
+ preserveTimestamps: true,
+ });
+ }
+ done.push(path.relative(dest.root, target).split(path.sep).join("/"));
+ }
+ return { copied: done };
+}
+
+// ── Trash ──────────────────────────────────────────────────────────────────
+
+const TRASH_ID_RE = /^[a-z0-9]{8,40}$/;
+
+/** Where `acct`'s (null: the host's, for AgentHub user `userKey`) trash lives. */
+function trashRoot(acct, userKey) {
+ if (acct) return path.join(acct.home, ".local", "share", "agenthub", "trash");
+ return path.join(getDataDir(), "trash", String(userKey || "local").replace(/[^\w.-]/g, "_"));
+}
+
+/** Move `rel` of workspace `dir` (workspace `laneId`) into its account's trash. */
+async function trashOne(dir, laneId, rel, userKey) {
+ const loc = await existing(dir, rel);
+ writable(loc);
+ const acct = accountAt(loc.abs);
+ const id = `${Date.now().toString(36)}${crypto.randomBytes(6).toString("hex")}`;
+ const idDir = path.join(trashRoot(acct, userKey), id);
+ const meta = JSON.stringify({
+ id,
+ name: loc.name,
+ laneId,
+ rel: loc.rel,
+ isDir: loc.st.isDirectory(),
+ deletedAt: new Date().toISOString(),
+ });
+ if (acct) {
+ needMove(acct);
+ runAs.mkdirAs(acct, idDir);
+ await runAs.writeAsAsync(acct, path.join(idDir, "meta.json"), ["private"], { input: meta });
+ await runAs.writeAsAsync(acct, loc.abs, ["move", path.join(idDir, "item")]);
+ } else {
+ await fs.promises.mkdir(idDir, { recursive: true, mode: 0o700 });
+ await fs.promises.writeFile(path.join(idDir, "meta.json"), meta, { mode: 0o600 });
+ await hostMove(loc.abs, path.join(idDir, "item"));
+ }
+ return id;
+}
+
+async function remove(dir, laneId, rels, userKey) {
+ const ids = [];
+ for (const r of rels) ids.push(await trashOne(dir, laneId, r, userKey));
+ return { trashed: ids };
+}
+
+function readMeta(acct, idDir) {
+ try {
+ const text = acct
+ ? runAs.readFileSafe(path.join(idDir, "meta.json"), "utf8", { max: 64 * 1024 })
+ : fs.readFileSync(path.join(idDir, "meta.json"), "utf8");
+ return JSON.parse(text);
+ } catch {
+ return null;
+ }
+}
+
+async function dropTrash(acct, idDir) {
+ if (acct) runAs.removeAs(acct, idDir);
+ else await fs.promises.rm(idDir, { recursive: true, force: true });
+}
+
+/** The trash of the account owning `dir`, expired entries dropped first. */
+async function listTrash(dir, userKey) {
+ const root = await laneRoot(dir);
+ const acct = accountAt(root);
+ const tr = trashRoot(acct, userKey);
+ let ids = [];
+ try {
+ ids = (await fs.promises.readdir(tr)).filter((n) => TRASH_ID_RE.test(n));
+ } catch {
+ return { items: [] };
+ }
+ const cutoff = Date.now() - TRASH_DAYS * 86_400_000;
+ const items = [];
+ for (const id of ids) {
+ const meta = readMeta(acct, path.join(tr, id));
+ if (!meta || Date.parse(meta.deletedAt) < cutoff) {
+ await dropTrash(acct, path.join(tr, id)).catch(() => {});
+ continue;
+ }
+ items.push({
+ id,
+ name: meta.name,
+ laneId: meta.laneId,
+ path: meta.rel,
+ isDir: !!meta.isDir,
+ deletedAt: meta.deletedAt,
+ });
+ }
+ items.sort((a, b) => b.deletedAt.localeCompare(a.deletedAt));
+ return { items, keepDays: TRASH_DAYS };
+}
+
+function trashEntry(acct, userKey, id) {
+ if (!TRASH_ID_RE.test(String(id || ""))) throw bad("EBADPATH", "no such trash item");
+ const idDir = path.join(trashRoot(acct, userKey), id);
+ const meta = readMeta(acct, idDir);
+ if (!meta) throw bad("ENOFILE", "no such trash item");
+ return { idDir, meta };
+}
+
+/**
+ * Put trash item `id` back where it was in workspace `dir` (its folders made
+ * again if gone; a taken name gets a free one). The item must belong to the
+ * account of `dir` — `resolveLane(meta.laneId)` returns that workspace's cwd
+ * when the caller may write there, else null (restored into `dir` instead).
+ */
+async function restore(dir, id, userKey, resolveLane) {
+ const root = await laneRoot(dir);
+ const acct = accountAt(root);
+ const { idDir, meta } = trashEntry(acct, userKey, id);
+ const home = (meta.laneId != null && resolveLane(meta.laneId)) || dir;
+ const parentRel = path.posix.dirname(String(meta.rel || meta.name));
+ const relDir = parentRel === "." ? "" : parentRel;
+ await ensureFolders(home, relDir);
+ const dest = await folder(home, relDir);
+ sameAccount(dest.abs, idDir);
+ let name = validName(meta.name);
+ if (await fs.promises.lstat(path.join(dest.abs, name)).catch(() => null)) {
+ name = await freeName(dest.abs, name);
+ }
+ const target = path.join(dest.abs, name);
+ await moveAs(acct, path.join(idDir, "item"), target);
+ await dropTrash(acct, idDir);
+ return { path: relDir ? `${relDir}/${name}` : name, laneId: home === dir ? null : meta.laneId };
+}
+
+async function purgeTrash(dir, userKey, id) {
+ const root = await laneRoot(dir);
+ const acct = accountAt(root);
+ if (id === "all") {
+ const { items } = await listTrash(dir, userKey);
+ for (const it of items) await dropTrash(acct, path.join(trashRoot(acct, userKey), it.id));
+ return { purged: items.length };
+ }
+ const { idDir } = trashEntry(acct, userKey, id);
+ await dropTrash(acct, idDir);
+ return { purged: 1 };
+}
+
+// ── Upload ─────────────────────────────────────────────────────────────────
+
+/** A pass-through that fails once more than `max` bytes went by. */
+function capped(max) {
+ let n = 0;
+ return new Transform({
+ transform(chunk, _enc, cb) {
+ n += chunk.length;
+ if (n > max) return cb(bad("ETOOBIG", `a file may be at most ${max / 1024 / 1024} MB`));
+ cb(null, chunk);
+ },
+ });
+}
+
+/**
+ * Write the request `stream` to `rel` (sub-folders made as needed). Returns
+ * `{path}`, or `{skipped: true}` under `conflict: "skip"`.
+ */
+async function upload(dir, rel, stream, { conflict, length, trash } = {}) {
+ const p = lanePath(rel);
+ for (const seg of p.split("/")) validName(seg);
+ if (gitInside(p)) throw bad("EBADPATH", "nothing is uploaded into .git");
+ if (length != null && Number(length) > UPLOAD_MAX_BYTES) {
+ throw bad("ETOOBIG", `a file may be at most ${UPLOAD_MAX_BYTES / 1024 / 1024} MB`);
+ }
+ const parentRel = path.posix.dirname(p) === "." ? "" : path.posix.dirname(p);
+ await ensureFolders(dir, parentRel);
+ let loc = await locate(dir, p);
+ const policy = conflictOf(conflict);
+ if (loc.st) {
+ if (loc.st.isDirectory()) throw bad("EEXISTS", `${loc.rel} is a folder`, { names: [loc.name] });
+ if (policy === "fail") throw bad("EEXISTS", `${loc.rel} already exists`, { names: [loc.name] });
+ if (policy === "skip") {
+ stream.resume();
+ return { path: loc.rel, skipped: true };
+ }
+ if (policy === "keep") {
+ const name = await freeName(loc.parent.abs, loc.name);
+ loc = await locate(dir, parentRel ? `${parentRel}/${name}` : name);
+ } else {
+ await trash(loc.rel);
+ }
+ }
+ const acct = accountAt(loc.abs);
+ const limit = capped(UPLOAD_MAX_BYTES);
+ if (acct) {
+ // write-as `replace`: a new file renamed over the name, as the member.
+ const done = runAs.writeAsAsync(acct, loc.abs, ["replace"], { input: limit });
+ await Promise.all([
+ pipeline(stream, limit).catch((e) => {
+ limit.destroy(e);
+ throw e;
+ }),
+ done,
+ ]);
+ } else {
+ const tmp = path.join(
+ loc.parent.abs,
+ `.${loc.name}.agenthub-upload-${crypto.randomBytes(4).toString("hex")}`
+ );
+ try {
+ await pipeline(stream, limit, fs.createWriteStream(tmp, { flags: "wx" }));
+ // Linked, not renamed: a name taken meanwhile is refused, never replaced.
+ await fs.promises.link(tmp, loc.abs).catch(async (e) => {
+ if (e.code === "EEXIST")
+ throw bad("EEXISTS", `${loc.rel} already exists`, { names: [loc.name] });
+ if (e.code === "EPERM" || e.code === "ENOTSUP") return fs.promises.rename(tmp, loc.abs);
+ throw e;
+ });
+ } finally {
+ await fs.promises.rm(tmp, { force: true });
+ }
+ }
+ return { path: loc.rel };
+}
+
+/**
+ * One file for a download (the Files tab's `openDownload`, after this
+ * module's own checks — the home rule included, on the file's REAL path).
+ */
+async function download(dir, rel) {
+ const loc = await existing(dir, rel);
+ const real = await fs.promises.realpath(loc.abs).catch(() => loc.abs);
+ if (topDot(loc.root, real) || escapes(loc.root, real)) throw hidden();
+ return require("./lane-file-transfer").openDownload(dir, loc.rel);
+}
+
+// ── Zip download ───────────────────────────────────────────────────────────
+
+/** Every regular file and folder under `rels` (no symlink followed, no secret). */
+async function collect(dir, rels) {
+ const root = await laneRoot(dir);
+ const files = [];
+ const dirs = [];
+ let total = 0;
+ const visit = async (abs, inZip) => {
+ const rel = path.relative(root, abs);
+ if (topDot(root, abs) || (await isSecretOnDisk(root, rel))) return;
+ const st = await fs.promises.lstat(abs).catch(() => null);
+ if (!st) return;
+ if (st.isDirectory()) {
+ dirs.push({ name: `${inZip}/`, mtime: st.mtime });
+ for (const n of (await fs.promises.readdir(abs)).sort())
+ await visit(path.join(abs, n), `${inZip}/${n}`);
+ } else if (st.isFile()) {
+ total += st.size;
+ if (total > ZIP_MAX_BYTES)
+ throw bad("ETOOBIG", "more than 2 GB to zip — download fewer files");
+ if (files.length >= ZIP_MAX_FILES) throw bad("ETOOBIG", "more than 50,000 files to zip");
+ files.push({ abs, name: inZip, size: st.size, mtime: st.mtime, mode: st.mode });
+ }
+ };
+ for (const r of rels) {
+ const loc = await existing(dir, r);
+ await visit(loc.abs, loc.name);
+ }
+ return { files, dirs, total };
+}
+
+function dosTime(d) {
+ const t = (d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1);
+ const day =
+ ((Math.max(1980, d.getFullYear()) - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate();
+ return { t, day };
+}
+
+/**
+ * Stream a zip of `rels` (files and folders) into `out`. Deflate with data
+ * descriptors, UTF-8 names, no ZIP64 (collect caps it at 2 GB). A member's
+ * file is opened through `openSafeAsync` (a regular file they own).
+ */
+async function zip(dir, rels, out) {
+ const { files, dirs } = await collect(dir, rels);
+ let offset = 0;
+ const central = [];
+ const write = (buf) =>
+ new Promise((resolve, reject) => {
+ offset += buf.length;
+ out.write(buf, (e) => (e ? reject(e) : resolve()));
+ });
+ const header = (name, method, flags, d) => {
+ const n = Buffer.from(name, "utf8");
+ const h = Buffer.alloc(30);
+ h.writeUInt32LE(0x04034b50, 0);
+ h.writeUInt16LE(20, 4);
+ h.writeUInt16LE(flags, 6);
+ h.writeUInt16LE(method, 8);
+ h.writeUInt16LE(d.t, 10);
+ h.writeUInt16LE(d.day, 12);
+ h.writeUInt16LE(n.length, 26);
+ return Buffer.concat([h, n]);
+ };
+ for (const d of dirs) {
+ const dt = dosTime(d.mtime);
+ central.push({
+ name: d.name,
+ method: 0,
+ flags: 0x0800,
+ dt,
+ crc: 0,
+ csize: 0,
+ size: 0,
+ offset,
+ attr: (0o40755 << 16) | 0x10,
+ });
+ await write(header(d.name, 0, 0x0800, dt));
+ }
+ for (const f of files) {
+ const dt = dosTime(f.mtime);
+ const start = offset;
+ await write(header(f.name, 8, 0x0808, dt));
+ let crc = 0;
+ let size = 0;
+ let csize = 0;
+ let fh;
+ try {
+ fh = await runAs.openSafeAsync(f.abs);
+ } catch {
+ fh = null; // gone, or not the member's own regular file: stored empty
+ }
+ const tap = new Transform({
+ transform(c, _e, cb) {
+ crc = zlib.crc32(c, crc);
+ size += c.length;
+ cb(null, c);
+ },
+ });
+ const count = new Transform({
+ transform(c, _e, cb) {
+ csize += c.length;
+ offset += c.length;
+ cb(null, c);
+ },
+ });
+ const src =
+ fh && f.size ? fh.createReadStream({ start: 0, end: f.size - 1 }) : Readable.from([]);
+ if (fh && !f.size) await fh.close();
+ await pipeline(src, tap, zlib.createDeflateRaw(), count, out, { end: false });
+ const dd = Buffer.alloc(16);
+ dd.writeUInt32LE(0x08074b50, 0);
+ dd.writeUInt32LE(crc >>> 0, 4);
+ dd.writeUInt32LE(csize, 8);
+ dd.writeUInt32LE(size, 12);
+ await write(dd);
+ central.push({
+ name: f.name,
+ method: 8,
+ flags: 0x0808,
+ dt,
+ crc,
+ csize,
+ size,
+ offset: start,
+ attr: ((f.mode & 0o777) | 0o100000) << 16,
+ });
+ }
+ const cdStart = offset;
+ for (const c of central) {
+ const n = Buffer.from(c.name, "utf8");
+ const h = Buffer.alloc(46);
+ h.writeUInt32LE(0x02014b50, 0);
+ h.writeUInt16LE((3 << 8) | 20, 4);
+ h.writeUInt16LE(20, 6);
+ h.writeUInt16LE(c.flags, 8);
+ h.writeUInt16LE(c.method, 10);
+ h.writeUInt16LE(c.dt.t, 12);
+ h.writeUInt16LE(c.dt.day, 14);
+ h.writeUInt32LE(c.crc >>> 0, 16);
+ h.writeUInt32LE(c.csize, 20);
+ h.writeUInt32LE(c.size, 24);
+ h.writeUInt16LE(n.length, 28);
+ h.writeUInt32LE(c.attr >>> 0, 38);
+ h.writeUInt32LE(c.offset, 42);
+ await write(Buffer.concat([h, n]));
+ }
+ const end = Buffer.alloc(22);
+ end.writeUInt32LE(0x06054b50, 0);
+ end.writeUInt16LE(central.length, 8);
+ end.writeUInt16LE(central.length, 10);
+ end.writeUInt32LE(offset - cdStart, 12);
+ end.writeUInt32LE(cdStart, 16);
+ await write(end);
+ out.end();
+}
+
+module.exports = {
+ inHome,
+ download,
+ list,
+ mkdir,
+ newFile,
+ rename,
+ move,
+ copy,
+ remove,
+ trashOne,
+ listTrash,
+ restore,
+ purgeTrash,
+ upload,
+ zip,
+ collect,
+ UPLOAD_MAX_BYTES,
+ ZIP_MAX_BYTES,
+ TRASH_DAYS,
+};
diff --git a/server/lib/homes.js b/server/lib/homes.js
new file mode 100644
index 00000000..b60335dd
--- /dev/null
+++ b/server/lib/homes.js
@@ -0,0 +1,91 @@
+/**
+ * @file Which HOME folder a user's File Explorer opens (`/api/homes/*`):
+ * - S3: their own Linux account's home (`/srv/agenthub/users/`);
+ * - server mode without S3: their own folder under AGENTHUB_USERS_ROOT;
+ * - local mode, or an admin with no Linux account of their own: the host
+ * account's home.
+ * `me` is always the caller's. An admin may also open another user's home —
+ * read-only (the routes refuse every write, `EREADONLY`) and written to the
+ * audit log — the "view other users' homes" option. A home is never a
+ * workspace (the S3 rule stands); it is browsed with `file-explorer.js`'s
+ * `inHome`, which keeps every dot entry at its top out of reach.
+ * @author Nguyễn Ngọc Trí Vĩ
+ */
+
+const fs = require("node:fs");
+const os = require("node:os");
+const path = require("node:path");
+
+const auth = require("./auth");
+const linux = require("./linux-accounts");
+const runAs = require("./run-as");
+const { ownFolder } = require("./scope");
+
+const isDir = (p) => {
+ try {
+ return fs.statSync(p).isDirectory();
+ } catch {
+ return false;
+ }
+};
+
+/** The home of AgentHub user `user` (a users row or req.user), or null. */
+function homeOf(user) {
+ if (!auth.isServerMode()) return os.homedir();
+ if (!user) return null;
+ if (linux.s3Enabled()) {
+ let acct = null;
+ try {
+ acct = runAs.accountForUser(user.username);
+ } catch {
+ acct = null; // disabled or not provisioned: no home to open
+ }
+ if (acct) return acct.home;
+ return user.role === "admin" ? os.homedir() : null;
+ }
+ const own = ownFolder({ user });
+ if (own) return isDir(own) ? own : null;
+ return user.role === "admin" ? os.homedir() : null;
+}
+
+/**
+ * The homes `req` may open: `{ id: "me" | , label, readOnly }`.
+ * Others' only for an admin, and only those that resolve to a folder.
+ */
+function listHomes(req) {
+ const out = [];
+ const me = req.user || null;
+ if (homeOf(me))
+ out.push({ id: "me", label: me?.displayName || me?.username || "", readOnly: false });
+ if (me?.role === "admin" && auth.isServerMode()) {
+ for (const u of auth.listUsers()) {
+ if (u.id === me.id || u.disabled) continue;
+ const home = homeOf(u);
+ if (home && home !== homeOf(me)) {
+ out.push({ id: u.username, label: u.displayName || u.username, readOnly: true });
+ }
+ }
+ }
+ return out;
+}
+
+/**
+ * `{ dir, readOnly, ownerId }` for home `who` of `req`, or a thrown ENOHOME
+ * (404) — never another user's for a non-admin.
+ */
+function resolveHome(req, who) {
+ const none = () => Object.assign(new Error("no such home"), { code: "ENOHOME" });
+ const me = req.user || null;
+ if (who === "me") {
+ const dir = homeOf(me);
+ if (!dir) throw none();
+ return { dir: path.resolve(dir), readOnly: false, ownerId: me?.id ?? null };
+ }
+ if (me?.role !== "admin" || !auth.isServerMode()) throw none();
+ const u = auth.listUsers().find((x) => x.username === String(who) && !x.disabled);
+ const dir = u && homeOf(u);
+ if (!dir) throw none();
+ return { dir: path.resolve(dir), readOnly: true, ownerId: u.id };
+}
+
+module.exports = { homeOf, listHomes, resolveHome };
diff --git a/server/lib/linux-accounts.js b/server/lib/linux-accounts.js
index b6080891..c8a1cf7c 100644
--- a/server/lib/linux-accounts.js
+++ b/server/lib/linux-accounts.js
@@ -29,12 +29,20 @@ const confPath = () => process.env.AGENTHUB_SERVER_CONF || "/etc/agenthub/server
// server.conf is read on every tmux call's account lookup — keep it 5 s.
let confCache = { at: 0, path: null, value: null };
+// The last server.conf that read well, per path. A server that started with
+// S3 keeps it if the file later fails to read (a transient error, an edit in
+// progress): falling back to "no S3" would run a member's pane as the HOST
+// account in their folder. Removing S3 for good is followed by a restart.
+let lastGood = { path: null, value: null };
/** `{hostUser, hostUid, usersRoot, port, bins}` from server.conf, or null. */
function serverConf() {
const file = confPath();
if (confCache.path === file && Date.now() - confCache.at < 5000) return confCache.value;
- confCache = { at: Date.now(), path: file, value: readConf(file) };
+ let value = readConf(file);
+ if (value) lastGood = { path: file, value };
+ else if (lastGood.path === file && require("./auth").isServerMode()) value = lastGood.value;
+ confCache = { at: Date.now(), path: file, value };
return confCache.value;
}
@@ -135,6 +143,7 @@ function provision(action, username) {
function __resetConfCache() {
confCache = { at: 0, path: null, value: null };
+ lastGood = { path: null, value: null };
}
module.exports = {
diff --git a/server/lib/proxy-gateway.js b/server/lib/proxy-gateway.js
index 1c86c0b9..e4356df7 100644
--- a/server/lib/proxy-gateway.js
+++ b/server/lib/proxy-gateway.js
@@ -34,7 +34,17 @@ const https = require("node:https");
const IDLE_MS = 10 * 60 * 1000;
/** Request headers never passed upstream: hop-by-hop, the pane's token, cookies. */
+/**
+ * Set on every request the gateway sends upstream. A personal profile may
+ * name ANY http(s) base — a loopback one included — and the gateway sends
+ * from the dashboard's own uid, which hook auth trusts. The dashboard refuses
+ * every request carrying this mark (server/index.js), so a profile pointed
+ * back at it can never reach a route as the host; a real proxy ignores it.
+ */
+const GATEWAY_MARK = "x-agenthub-via-gateway";
+
const DROP_REQUEST = new Set([
+ GATEWAY_MARK,
"host",
"connection",
"keep-alive",
@@ -151,6 +161,7 @@ function proxyGateway(req, res) {
headers.authorization = `Bearer ${profile.apiKey}`;
headers["x-api-key"] = profile.apiKey;
}
+ headers[GATEWAY_MARK] = "1";
const mod = target.protocol === "https:" ? https : http;
const up = mod.request(target, { method: req.method, headers }, (answer) => {
@@ -173,4 +184,10 @@ function proxyGateway(req, res) {
req.pipe(up);
}
-module.exports = { proxyGateway, gatewayBase, setListenPort, listenPort: currentPort };
+module.exports = {
+ proxyGateway,
+ gatewayBase,
+ setListenPort,
+ listenPort: currentPort,
+ GATEWAY_MARK,
+};
diff --git a/server/lib/run-as.js b/server/lib/run-as.js
index d1bd093e..f06fe69a 100644
--- a/server/lib/run-as.js
+++ b/server/lib/run-as.js
@@ -254,6 +254,53 @@ function writeAs(acct, file, mode, content = "") {
execImpl(cmd, argv, { ...MEMBER_EXEC, input: content, stdio: ["pipe", "ignore", "pipe"] });
}
+// Test seam: how an ASYNC write-as runs (spawn-like: returns a child).
+let spawnImpl = (cmd, argv, opts) => require("node:child_process").spawn(cmd, argv, opts);
+function __setSpawnImpl(fn) {
+ spawnImpl = fn || ((cmd, argv, opts) => require("node:child_process").spawn(cmd, argv, opts));
+}
+
+/**
+ * `write-as ` as `acct`, without blocking the event loop — for
+ * what may be big or slow (an upload streamed from the request, a recursive
+ * copy, a move across filesystems). `input`: a Buffer/string, or a readable
+ * stream piped to stdin (never argv: sudo logs it). Killed after `timeout`.
+ */
+function writeAsAsync(acct, file, args = [], { input = null, timeout = 10 * 60_000 } = {}) {
+ const [cmd, argv] = sudoArgv(acct, WRITE_AS, [file, ...args]);
+ return new Promise((resolve, reject) => {
+ const child = spawnImpl(cmd, argv, { stdio: ["pipe", "ignore", "pipe"] });
+ let stderr = "";
+ child.stderr?.on("data", (d) => (stderr += d));
+ const timer = setTimeout(() => child.kill("SIGKILL"), timeout);
+ child.on("error", (e) => {
+ clearTimeout(timer);
+ reject(e);
+ });
+ child.on("close", (code) => {
+ clearTimeout(timer);
+ if (code === 0) return resolve();
+ reject(
+ Object.assign(new Error(stderr.trim() || `write-as exited with ${code}`), {
+ code: "EWRITEAS",
+ })
+ );
+ });
+ child.stdin.on("error", () => {});
+ if (input && typeof input.pipe === "function") input.pipe(child.stdin);
+ else child.stdin.end(input ?? "");
+ });
+}
+
+/** Whether the installed write-as knows `move`/`copy` (install-system re-run). */
+function writeAsCanMove() {
+ try {
+ return /^\s*move\)/m.test(fs.readFileSync(WRITE_AS, "utf8"));
+ } catch {
+ return false;
+ }
+}
+
/**
* Save `content` to `file` — as the member who owns the folder (S3), else
* directly. In place, keeping the file's mode; `append` adds to the end.
@@ -358,6 +405,13 @@ const PANE_LAUNCH = "/opt/agenthub/libexec/pane-launch";
*/
function launchArgv(acct, env = {}, argv = []) {
const vars = Object.entries(env || {}).map(([k, v]) => `${k}=${v}`);
+ // The file is NUL-separated: a NUL inside a value (a prompt) would split it
+ // into extra argv elements — flags the request never asked for.
+ if ([...vars, ...argv].some((x) => String(x).includes("\0"))) {
+ throw Object.assign(new Error("a NUL byte in a pane's argv or environment"), {
+ code: "EBADARG",
+ });
+ }
const body = [String(vars.length), ...vars, ...argv].map((x) => `${x}\0`).join("");
const name = `launch-${require("node:crypto").randomBytes(12).toString("hex")}`;
return [PANE_LAUNCH, handoffWrite(acct, name, body, "private")];
@@ -578,6 +632,9 @@ module.exports = {
accountForUser,
handoffWrite,
writeAs,
+ writeAsAsync,
+ writeAsCanMove,
+ __setSpawnImpl,
mkdirAs,
makeMemberFolder,
removeAs,
diff --git a/server/lib/scope.js b/server/lib/scope.js
index 6f4e7471..5eb04d10 100644
--- a/server/lib/scope.js
+++ b/server/lib/scope.js
@@ -223,7 +223,7 @@ function scopeGuard(req, res, next) {
const m = /^\/(?:lanes|workspaces)\/(\d+)(?:\/|$)/i.exec(p);
const ids = [...(m ? [m[1]] : []), ...lanes];
if (ids.some((id) => !ownerStillFits(Number(id)))) {
- return res.status(403).json({
+ return res.status(409).json({
error: {
code: "EOWNERFOLDER",
message: "this workspace runs as the host account: only an admin may change it",
diff --git a/server/lib/tmux.js b/server/lib/tmux.js
index bbdbd097..77d0a3a6 100644
--- a/server/lib/tmux.js
+++ b/server/lib/tmux.js
@@ -150,7 +150,9 @@ function accountOf(name) {
} catch (err) {
// A member's workspace with no usable account never falls back to the
// host's server: that would run the pane as the host in their folder.
- if (err.code === "EMEMBERGONE") throw err;
+ // Under S3 neither does any other failure to tell (the DB unreadable
+ // mid-call): the call fails instead of guessing "the host".
+ if (err.code === "EMEMBERGONE" || require("./linux-accounts").s3Enabled()) throw err;
return null; // no DB yet, a malformed name: the host's own server
}
}
diff --git a/server/openapi-extra.js b/server/openapi-extra.js
index ef73a514..e54afc34 100644
--- a/server/openapi-extra.js
+++ b/server/openapi-extra.js
@@ -18,6 +18,7 @@ const push = require("./openapi-extra/push");
const aiTools = require("./openapi-extra/ai-tools");
const misc = require("./openapi-extra/misc");
const auth = require("./openapi-extra/auth");
+const files = require("./openapi-extra/files");
// Enriched overrides of already-documented endpoints — same operationId, tags,
// and request/response `$ref` schemas as the base, with added examples and
// richer descriptions. Listed last so they win the merge.
@@ -54,6 +55,7 @@ module.exports = combine(
aiTools,
misc,
auth,
+ files,
overrideSessionsAgents,
overrideCore,
overridePricingAlerts,
diff --git a/server/openapi-extra/files.js b/server/openapi-extra/files.js
new file mode 100644
index 00000000..8c8f735b
--- /dev/null
+++ b/server/openapi-extra/files.js
@@ -0,0 +1,324 @@
+/**
+ * @file OpenAPI fragment for the File Explorer — the same operations on a
+ * workspace (`/api/lanes/{id}/fs/*`, also under /api/workspaces) and on a
+ * user's home (`/api/homes/{who}/fs/*`), generated from one table so the two
+ * cannot drift — plus `GET /api/homes`, Add workspace → Clone
+ * (`POST /api/lanes/clone`) and the user's own git identity (`/api/me/git`).
+ * @author Nguyễn Ngọc Trí Vĩ
+ */
+
+const tags = [
+ {
+ name: "Files",
+ description:
+ "The File Explorer on a workspace or a user's home: list, make, rename, move, copy, delete into a restorable trash, streamed upload, zip download. Writes run as the folder's account; a taken name is settled by `conflict`.",
+ },
+ {
+ name: "Git identity",
+ description:
+ "A user's own SSH key and HTTPS logins and commit author, and cloning a repository into a new workspace with them.",
+ },
+];
+
+const errorCodes =
+ "400 EBADPATH/EBADNAME, 403 ESECRET/EFOREIGN/EREADONLY (another user's home)/EBADORIGIN, 404 ENOFILE/ENOHOME, 409 EEXISTS (with `error.names`)/EUPGRADE/EMEMBERGONE, 413 ETOOBIG.";
+
+const schemas = {
+ FsEntry: {
+ type: "object",
+ properties: {
+ name: { type: "string" },
+ path: { type: "string", description: "Relative to the workspace or home." },
+ type: { type: "string", enum: ["dir", "file", "link", "linkdir", "other"] },
+ size: { type: "integer", nullable: true },
+ mtime: { type: "number", description: "Milliseconds since the epoch." },
+ },
+ },
+ FsListing: {
+ type: "object",
+ properties: {
+ path: { type: "string" },
+ parent: { type: "string", nullable: true },
+ entries: { type: "array", items: { $ref: "#/components/schemas/FsEntry" } },
+ truncated: { type: "boolean" },
+ },
+ },
+ FsConflict: {
+ type: "string",
+ enum: ["fail", "skip", "keep", "replace"],
+ default: "fail",
+ description:
+ "A taken name: `fail` answers 409 EEXISTS with the names, `skip` leaves it, `keep` uses a free `name (1).ext`, `replace` sends the old one to the trash first.",
+ },
+};
+
+const json = (properties, required = []) => ({
+ required: true,
+ content: { "application/json": { schema: { type: "object", required, properties } } },
+});
+const pathsArr = { type: "array", items: { type: "string" } };
+const conflict = { $ref: "#/components/schemas/FsConflict" };
+
+/** The operations, without their prefix: [method, sub, summary, extra]. */
+const OPS = [
+ [
+ "get",
+ "/list",
+ "List a folder (folders first)",
+ {
+ parameters: [{ name: "path", in: "query", schema: { type: "string", default: "" } }],
+ ok: {
+ description: "The folder.",
+ content: { "application/json": { schema: { $ref: "#/components/schemas/FsListing" } } },
+ },
+ },
+ ],
+ [
+ "post",
+ "/mkdir",
+ "Make a folder",
+ { body: json({ path: { type: "string" } }, ["path"]), status: 201 },
+ ],
+ [
+ "post",
+ "/newfile",
+ "Make an empty file",
+ { body: json({ path: { type: "string" } }, ["path"]), status: 201 },
+ ],
+ [
+ "post",
+ "/rename",
+ "Rename in place",
+ { body: json({ path: { type: "string" }, name: { type: "string" } }, ["path", "name"]) },
+ ],
+ [
+ "post",
+ "/move",
+ "Move items into a folder",
+ { body: json({ paths: pathsArr, dest: { type: "string" }, conflict }, ["paths", "dest"]) },
+ ],
+ [
+ "post",
+ "/copy",
+ "Copy items into a folder — from here, another workspace (`fromLane`) or a home (`fromHome`) of the same account",
+ {
+ body: json(
+ {
+ paths: pathsArr,
+ dest: { type: "string" },
+ conflict,
+ fromLane: { type: "integer" },
+ fromHome: { type: "string" },
+ },
+ ["paths", "dest"]
+ ),
+ },
+ ],
+ [
+ "post",
+ "/delete",
+ "Move items into the account's trash (restorable for 7 days)",
+ { body: json({ paths: pathsArr }, ["paths"]) },
+ ],
+ ["get", "/trash", "The trash, newest first (entries older than 7 days are dropped)", {}],
+ [
+ "post",
+ "/trash/restore",
+ "Put a trash item back where it was",
+ { body: json({ id: { type: "string" } }, ["id"]) },
+ ],
+ [
+ "post",
+ "/trash/purge",
+ "Delete a trash item (or `all`) for good",
+ { body: json({ id: { type: "string" } }, ["id"]) },
+ ],
+ [
+ "post",
+ "/upload",
+ "Upload one file, streamed, making missing sub-folders (≤ 500 MB)",
+ {
+ parameters: [
+ { name: "path", in: "query", required: true, schema: { type: "string" } },
+ { name: "conflict", in: "query", schema: conflict },
+ ],
+ body: {
+ required: true,
+ content: { "application/octet-stream": { schema: { type: "string", format: "binary" } } },
+ },
+ status: 201,
+ },
+ ],
+ [
+ "get",
+ "/zip",
+ "Files and folders as one streamed zip (≤ 2 GB, 50,000 files)",
+ {
+ parameters: [
+ {
+ name: "p",
+ in: "query",
+ required: true,
+ schema: { type: "array", items: { type: "string" } },
+ style: "form",
+ explode: true,
+ },
+ ],
+ ok: {
+ description: "application/zip",
+ content: { "application/zip": { schema: { type: "string", format: "binary" } } },
+ },
+ },
+ ],
+];
+
+function operations(prefix, idParam, opPrefix, note) {
+ const out = {};
+ for (const [method, sub, summary, x] of OPS) {
+ const key = `${prefix}${sub}`;
+ out[key] = out[key] || {};
+ out[key][method] = {
+ tags: ["Files"],
+ summary,
+ description: `${note} ${errorCodes}`,
+ operationId: `${opPrefix}${sub.replace(/\/(\w)/g, (_, c) => c.toUpperCase())}`,
+ parameters: [idParam, ...(x.parameters || [])],
+ ...(x.body ? { requestBody: x.body } : {}),
+ responses: { [x.status || 200]: x.ok || { description: "Done." } },
+ };
+ }
+ return out;
+}
+
+const paths = {
+ ...operations(
+ "/api/lanes/{id}/fs",
+ { name: "id", in: "path", required: true, schema: { type: "integer" } },
+ "laneFs",
+ "On a workspace (`?session=` / `?source=` pick the session worktree / extra folder, like the git reads)."
+ ),
+ ...operations(
+ "/api/homes/{who}/fs",
+ {
+ name: "who",
+ in: "path",
+ required: true,
+ schema: { type: "string" },
+ description: "`me`, or another user's name (an admin's read-only view).",
+ },
+ "homeFs",
+ "On a user's home. Every dot entry at the top of the home is hidden and refused."
+ ),
+ "/api/lanes/{id}/fs/download": undefined,
+ "/api/homes/{who}/fs/download": {
+ get: {
+ tags: ["Files"],
+ summary: "Download one file of a home",
+ operationId: "homeFsDownload",
+ parameters: [
+ { name: "who", in: "path", required: true, schema: { type: "string" } },
+ { name: "path", in: "query", required: true, schema: { type: "string" } },
+ ],
+ responses: { 200: { description: "The file, as a sandboxed attachment." } },
+ },
+ },
+ "/api/homes": {
+ get: {
+ tags: ["Files"],
+ summary: "The homes the caller may open",
+ description: "`me` (their own), and for an admin every other user's — read-only.",
+ operationId: "listHomes",
+ responses: { 200: { description: "{homes: [{id, label, readOnly}]}" } },
+ },
+ },
+ "/api/lanes/clone": {
+ post: {
+ tags: ["Git identity"],
+ summary: "Clone a repository into a new folder and make it a workspace",
+ description:
+ "`url`: https:// (no userinfo), ssh:// or user@host:path. Runs as the folder's account with that account's own SSH key or HTTPS login. 409 ECREDENTIALS (`error.auth` names the host) asks for `credentials`; 409 ESSHAUTH: the key was refused; 409 ENOREPO: no such repository.",
+ operationId: "cloneLane",
+ requestBody: json(
+ {
+ url: { type: "string" },
+ parent: { type: "string" },
+ name: { type: "string" },
+ title: { type: "string" },
+ tool: { type: "string" },
+ credentials: {
+ type: "object",
+ properties: {
+ username: { type: "string" },
+ password: { type: "string" },
+ remember: { type: "boolean" },
+ },
+ },
+ },
+ ["url", "parent"]
+ ),
+ responses: { 201: { description: "{lane, created: true, path}" } },
+ },
+ },
+ "/api/me/git": {
+ get: {
+ tags: ["Git identity"],
+ summary: "The caller's own git identity",
+ description: "Only the public SSH key and `hasToken` are ever returned.",
+ operationId: "getGitIdentity",
+ responses: {
+ 200: {
+ description:
+ "{account, writable, ssh: {publicKey, fingerprint} | null, https: [...], author: {name, email}}",
+ },
+ },
+ },
+ },
+ "/api/me/git/ssh-key": {
+ post: {
+ tags: ["Git identity"],
+ summary: "Make (or with `replace`, replace) the SSH key",
+ operationId: "generateSshKey",
+ requestBody: json({ replace: { type: "boolean" } }),
+ responses: { 201: { description: "The identity." } },
+ },
+ delete: {
+ tags: ["Git identity"],
+ summary: "Delete the SSH key",
+ operationId: "deleteSshKey",
+ responses: { 200: { description: "The identity." } },
+ },
+ },
+ "/api/me/git/https": {
+ post: {
+ tags: ["Git identity"],
+ summary: "Save an HTTPS login (never returned)",
+ operationId: "saveHttpsLogin",
+ requestBody: json(
+ { host: { type: "string" }, username: { type: "string" }, password: { type: "string" } },
+ ["host", "username", "password"]
+ ),
+ responses: { 200: { description: "The identity." } },
+ },
+ },
+ "/api/me/git/https/{host}": {
+ delete: {
+ tags: ["Git identity"],
+ summary: "Delete an HTTPS login",
+ operationId: "deleteHttpsLogin",
+ parameters: [{ name: "host", in: "path", required: true, schema: { type: "string" } }],
+ responses: { 200: { description: "The identity." } },
+ },
+ },
+ "/api/me/git/author": {
+ put: {
+ tags: ["Git identity"],
+ summary: "Set the commit author (`git config --global user.name/email`)",
+ operationId: "setGitAuthor",
+ requestBody: json({ name: { type: "string" }, email: { type: "string" } }, ["name", "email"]),
+ responses: { 200: { description: "The identity." }, 400: { description: "EBADAUTHOR" } },
+ },
+ },
+};
+delete paths["/api/lanes/{id}/fs/download"];
+
+module.exports = { tags, schemas, paths };
diff --git a/server/routes/homes.js b/server/routes/homes.js
new file mode 100644
index 00000000..131e9527
--- /dev/null
+++ b/server/routes/homes.js
@@ -0,0 +1,260 @@
+/**
+ * @file `/api/homes` — the File Explorer on a user's HOME (server/lib/homes.js
+ * decides which folder; server/lib/file-explorer.js does the work, inside
+ * `inHome` so the dot entries at the top of a home stay out of reach).
+ *
+ * GET /api/homes the homes the caller may open
+ * GET /api/homes/:who/fs/list|trash|zip|download
+ * POST /api/homes/:who/fs/mkdir|newfile|rename|move|copy|delete|upload|trash/restore|trash/purge
+ *
+ * `:who` is `me`, or — for an admin — another user's name: read-only
+ * (`403 EREADONLY` on every write) and written to the audit log. Writes are
+ * same-origin; a copy may come from a workspace (`fromLane`) the caller can
+ * see, of the same Linux account.
+ * @author Nguyễn Ngọc Trí Vĩ
+ */
+
+const express = require("express");
+const path = require("node:path");
+
+const fx = require("../lib/file-explorer");
+const homes = require("../lib/homes");
+const scope = require("../lib/scope");
+const lanesLib = require("../lib/lanes");
+const { sameOriginGuard } = require("../lib/same-origin");
+
+const router = express.Router();
+
+function fail(res, err) {
+ const status =
+ {
+ ENOHOME: 404,
+ EBADPATH: 400,
+ EBADNAME: 400,
+ ENOFILE: 404,
+ ESECRET: 403,
+ EFOREIGN: 403,
+ EREADONLY: 403,
+ EEXISTS: 409,
+ EUPGRADE: 409,
+ EMEMBERGONE: 409,
+ ETOOBIG: 413,
+ }[err.code] ?? 500;
+ res.status(status).json({
+ error: {
+ code: err.code || "EFS",
+ message: err.message,
+ ...(Array.isArray(err.names) ? { names: err.names } : {}),
+ },
+ });
+}
+
+const userKey = (req) => (req.user ? String(req.user.id) : "local");
+const paths = (body) =>
+ Array.isArray(body?.paths) ? body.paths.filter((p) => typeof p === "string") : [];
+
+/**
+ * Resolve `:who`, refuse a write to someone else's home, audit an admin's
+ * read of it, then run `fn(home)` inside the home rule.
+ */
+function handle(fn, { write = false } = {}) {
+ return async (req, res) => {
+ let home;
+ try {
+ home = homes.resolveHome(req, req.params.who);
+ if (write && home.readOnly) {
+ throw Object.assign(new Error("another user's home is read-only"), { code: "EREADONLY" });
+ }
+ if (home.readOnly && req.user) {
+ require("../lib/audit").recordHomeRead(
+ req.user.id,
+ home.ownerId,
+ req.method,
+ `home ${req.params.who}: ${req.path} ${String(req.query.path ?? "")}`
+ );
+ }
+ await fx.inHome(() => fn(req, res, home));
+ } catch (err) {
+ if (!res.headersSent) fail(res, err);
+ else res.destroy();
+ }
+ };
+}
+const trashFor = (req, home) => (rel) => fx.trashOne(home.dir, null, rel, userKey(req));
+
+router.get("/", (req, res) => {
+ res.json({ homes: homes.listHomes(req) });
+});
+
+router.get(
+ "/:who/fs/list",
+ handle(async (req, res, home) => {
+ res.json(await fx.list(home.dir, typeof req.query.path === "string" ? req.query.path : ""));
+ })
+);
+
+router.get(
+ "/:who/fs/download",
+ handle(async (req, res, home) => {
+ const file = await fx.download(home.dir, String(req.query.path ?? ""));
+ const ascii = file.name.replace(/[^\x20-\x7e]|["\\]/g, "_");
+ res.set({
+ "Content-Type": "application/octet-stream",
+ "Content-Length": String(file.size),
+ "Content-Disposition": `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(file.name)}`,
+ "Content-Security-Policy": "default-src 'none'; sandbox",
+ "X-Content-Type-Options": "nosniff",
+ "Cache-Control": "no-store",
+ });
+ file.stream.on("error", () => res.destroy());
+ file.stream.pipe(res);
+ })
+);
+
+router.get(
+ "/:who/fs/zip",
+ handle(async (req, res, home) => {
+ const ps = [].concat(req.query.p ?? []).filter((p) => typeof p === "string");
+ if (!ps.length) {
+ throw Object.assign(new Error("name at least one path"), { code: "EBADPATH" });
+ }
+ await fx.collect(home.dir, ps);
+ const base = ps.length === 1 ? path.posix.basename(ps[0]) : "home";
+ const name = `${base}.zip`;
+ const ascii = name.replace(/[^\x20-\x7e]|["\\]/g, "_");
+ res.set({
+ "Content-Type": "application/zip",
+ "Content-Disposition": `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`,
+ "Content-Security-Policy": "default-src 'none'; sandbox",
+ "X-Content-Type-Options": "nosniff",
+ "Cache-Control": "no-store",
+ });
+ await fx.zip(home.dir, ps, res);
+ })
+);
+
+router.get(
+ "/:who/fs/trash",
+ handle(async (req, res, home) => {
+ const out = await fx.listTrash(home.dir, userKey(req));
+ out.items = out.items.filter((i) => i.laneId == null || scope.canSeeLane(req, i.laneId));
+ res.json(out);
+ })
+);
+
+const W = { write: true };
+router.post(
+ "/:who/fs/mkdir",
+ sameOriginGuard,
+ handle(
+ async (req, res, home) => res.status(201).json(await fx.mkdir(home.dir, req.body?.path)),
+ W
+ )
+);
+router.post(
+ "/:who/fs/newfile",
+ sameOriginGuard,
+ handle(
+ async (req, res, home) => res.status(201).json(await fx.newFile(home.dir, req.body?.path)),
+ W
+ )
+);
+router.post(
+ "/:who/fs/rename",
+ sameOriginGuard,
+ handle(
+ async (req, res, home) => res.json(await fx.rename(home.dir, req.body?.path, req.body?.name)),
+ W
+ )
+);
+router.post(
+ "/:who/fs/move",
+ sameOriginGuard,
+ handle(
+ async (req, res, home) =>
+ res.json(
+ await fx.move(home.dir, paths(req.body), String(req.body?.dest ?? ""), {
+ conflict: req.body?.conflict,
+ trash: trashFor(req, home),
+ })
+ ),
+ W
+ )
+);
+router.post(
+ "/:who/fs/copy",
+ sameOriginGuard,
+ handle(async (req, res, home) => {
+ let srcDir = home.dir;
+ if (req.body?.fromLane != null) {
+ const from = /^\d+$/.test(String(req.body.fromLane))
+ ? lanesLib.getLane(Number(req.body.fromLane))
+ : null;
+ if (!from || !scope.canSeeLane(req, from.id)) {
+ throw Object.assign(new Error("workspace not found"), { code: "ENOFILE" });
+ }
+ srcDir = from.cwd;
+ }
+ const srcHome = srcDir === home.dir;
+ res.json(
+ await fx.copy(home.dir, paths(req.body), String(req.body?.dest ?? ""), {
+ srcDir,
+ srcHome,
+ conflict: req.body?.conflict,
+ trash: trashFor(req, home),
+ })
+ );
+ }, W)
+);
+router.post(
+ "/:who/fs/delete",
+ sameOriginGuard,
+ handle(
+ async (req, res, home) =>
+ res.json(await fx.remove(home.dir, null, paths(req.body), userKey(req))),
+ W
+ )
+);
+router.post(
+ "/:who/fs/trash/restore",
+ sameOriginGuard,
+ handle(async (req, res, home) => {
+ const resolveLane = (laneId) => {
+ const l = lanesLib.getLane(Number(laneId));
+ return l && scope.canSeeLane(req, l.id) ? l.cwd : null;
+ };
+ res.json(await fx.restore(home.dir, req.body?.id, userKey(req), resolveLane));
+ }, W)
+);
+router.post(
+ "/:who/fs/trash/purge",
+ sameOriginGuard,
+ handle(
+ async (req, res, home) => res.json(await fx.purgeTrash(home.dir, userKey(req), req.body?.id)),
+ W
+ )
+);
+router.post(
+ "/:who/fs/upload",
+ sameOriginGuard,
+ handle(async (req, res, home) => {
+ try {
+ const out = await fx.upload(
+ home.dir,
+ typeof req.query.path === "string" ? req.query.path : "",
+ req,
+ {
+ conflict: req.query.conflict,
+ length: req.headers["content-length"],
+ trash: trashFor(req, home),
+ }
+ );
+ res.status(out.skipped ? 200 : 201).json(out);
+ } catch (err) {
+ req.resume();
+ throw err;
+ }
+ }, W)
+);
+
+module.exports = router;
diff --git a/server/routes/lanes.js b/server/routes/lanes.js
index 146a74b5..476439cc 100644
--- a/server/routes/lanes.js
+++ b/server/routes/lanes.js
@@ -1457,6 +1457,241 @@ router.post("/:id/git/upload", sameOriginGuard, uploadBody, async (req, res) =>
}
});
+// ── File Explorer (server/lib/file-explorer.js) ────────────────────────────
+// Every route resolves its folder like the git reads (`gitTargetOr404`:
+// `?session=` / `?source=`), every write is same-origin and runs as the
+// folder's account. `conflict` (fail|skip|keep|replace) settles a taken
+// name; 409 EEXISTS carries `names` so the browser can ask.
+const fileExplorer = require("../lib/file-explorer");
+
+function fsFail(res, err) {
+ const status =
+ {
+ EBADPATH: 400,
+ EBADNAME: 400,
+ ENOFILE: 404,
+ ESECRET: 403,
+ EFOREIGN: 403,
+ EEXISTS: 409,
+ EUPGRADE: 409,
+ EMEMBERGONE: 409,
+ ETOOBIG: 413,
+ ENOHOME: 404,
+ }[err.code] ?? 500;
+ res.status(status).json({
+ error: {
+ code: err.code || "EFS",
+ message: err.message,
+ ...(Array.isArray(err.names) ? { names: err.names } : {}),
+ },
+ });
+}
+
+const fsUserKey = (req) => (req.user ? String(req.user.id) : "local");
+const fsPaths = (body) =>
+ Array.isArray(body?.paths) ? body.paths.filter((p) => typeof p === "string") : [];
+/** The trash a `replace` sends the old file to, for this workspace. */
+const fsTrashFor = (req, t) => (rel) =>
+ fileExplorer.trashOne(t.cwd, t.lane.id, rel, fsUserKey(req));
+
+router.get("/:id/fs/list", async (req, res) => {
+ const t = await gitTargetOr404(req, res);
+ if (!t) return;
+ try {
+ res.json(
+ await fileExplorer.list(t.cwd, typeof req.query.path === "string" ? req.query.path : "")
+ );
+ } catch (err) {
+ fsFail(res, err);
+ }
+});
+
+router.post("/:id/fs/mkdir", sameOriginGuard, async (req, res) => {
+ const t = await gitTargetOr404(req, res);
+ if (!t) return;
+ try {
+ res.status(201).json(await fileExplorer.mkdir(t.cwd, req.body?.path));
+ } catch (err) {
+ fsFail(res, err);
+ }
+});
+
+router.post("/:id/fs/newfile", sameOriginGuard, async (req, res) => {
+ const t = await gitTargetOr404(req, res);
+ if (!t) return;
+ try {
+ res.status(201).json(await fileExplorer.newFile(t.cwd, req.body?.path));
+ } catch (err) {
+ fsFail(res, err);
+ }
+});
+
+router.post("/:id/fs/rename", sameOriginGuard, async (req, res) => {
+ const t = await gitTargetOr404(req, res);
+ if (!t) return;
+ try {
+ res.json(await fileExplorer.rename(t.cwd, req.body?.path, req.body?.name));
+ } catch (err) {
+ fsFail(res, err);
+ }
+});
+
+router.post("/:id/fs/move", sameOriginGuard, async (req, res) => {
+ const t = await gitTargetOr404(req, res);
+ if (!t) return;
+ try {
+ res.json(
+ await fileExplorer.move(t.cwd, fsPaths(req.body), String(req.body?.dest ?? ""), {
+ conflict: req.body?.conflict,
+ trash: fsTrashFor(req, t),
+ })
+ );
+ } catch (err) {
+ fsFail(res, err);
+ }
+});
+
+/**
+ * Copy into this workspace — from itself, or (`fromLane`) from another
+ * workspace the caller can see; both must belong to one account (EFOREIGN).
+ */
+router.post("/:id/fs/copy", sameOriginGuard, async (req, res) => {
+ const t = await gitTargetOr404(req, res);
+ if (!t) return;
+ let srcDir = t.cwd;
+ let srcHome = false;
+ if (typeof req.body?.fromHome === "string") {
+ // From a home in the File Explorer (`me`, or an admin's read-only view).
+ try {
+ srcDir = require("../lib/homes").resolveHome(req, req.body.fromHome).dir;
+ srcHome = true;
+ } catch (err) {
+ return fsFail(res, err);
+ }
+ } else if (req.body?.fromLane != null && Number(req.body.fromLane) !== t.lane.id) {
+ const from = /^\d+$/.test(String(req.body.fromLane))
+ ? lanesLib.getLane(Number(req.body.fromLane))
+ : null;
+ if (!from || !scope.canSeeLane(req, from.id)) {
+ return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
+ }
+ srcDir = from.cwd;
+ }
+ try {
+ res.json(
+ await fileExplorer.copy(t.cwd, fsPaths(req.body), String(req.body?.dest ?? ""), {
+ srcDir,
+ srcHome,
+ conflict: req.body?.conflict,
+ trash: fsTrashFor(req, t),
+ })
+ );
+ } catch (err) {
+ fsFail(res, err);
+ }
+});
+
+/** Delete = move into the account's trash (restorable for TRASH_DAYS). */
+router.post("/:id/fs/delete", sameOriginGuard, async (req, res) => {
+ const t = await gitTargetOr404(req, res);
+ if (!t) return;
+ try {
+ res.json(await fileExplorer.remove(t.cwd, t.lane.id, fsPaths(req.body), fsUserKey(req)));
+ } catch (err) {
+ fsFail(res, err);
+ }
+});
+
+router.get("/:id/fs/trash", async (req, res) => {
+ const t = await gitTargetOr404(req, res);
+ if (!t) return;
+ try {
+ const out = await fileExplorer.listTrash(t.cwd, fsUserKey(req));
+ // Only what came from workspaces this caller can see.
+ out.items = out.items.filter((i) => i.laneId == null || scope.canSeeLane(req, i.laneId));
+ res.json(out);
+ } catch (err) {
+ fsFail(res, err);
+ }
+});
+
+router.post("/:id/fs/trash/restore", sameOriginGuard, async (req, res) => {
+ const t = await gitTargetOr404(req, res);
+ if (!t) return;
+ try {
+ const resolveLane = (laneId) => {
+ const l = lanesLib.getLane(Number(laneId));
+ return l && scope.canSeeLane(req, l.id) ? l.cwd : null;
+ };
+ res.json(await fileExplorer.restore(t.cwd, req.body?.id, fsUserKey(req), resolveLane));
+ } catch (err) {
+ fsFail(res, err);
+ }
+});
+
+router.post("/:id/fs/trash/purge", sameOriginGuard, async (req, res) => {
+ const t = await gitTargetOr404(req, res);
+ if (!t) return;
+ try {
+ res.json(await fileExplorer.purgeTrash(t.cwd, fsUserKey(req), req.body?.id));
+ } catch (err) {
+ fsFail(res, err);
+ }
+});
+
+/**
+ * Upload one file, streamed (no body parser buffers it), to `?path=` — any
+ * missing sub-folders are made (a folder upload sends each file with its
+ * relative path). Up to UPLOAD_MAX_BYTES; `?conflict=` settles a taken name.
+ */
+router.post("/:id/fs/upload", sameOriginGuard, async (req, res) => {
+ const t = await gitTargetOr404(req, res);
+ if (!t) return;
+ try {
+ const out = await fileExplorer.upload(
+ t.cwd,
+ typeof req.query.path === "string" ? req.query.path : "",
+ req,
+ {
+ conflict: req.query.conflict,
+ length: req.headers["content-length"],
+ trash: fsTrashFor(req, t),
+ }
+ );
+ res.status(out.skipped ? 200 : 201).json(out);
+ } catch (err) {
+ req.resume();
+ fsFail(res, err);
+ }
+});
+
+/** Files and folders as one zip, streamed (`?p=` once per path). */
+router.get("/:id/fs/zip", async (req, res) => {
+ const t = await gitTargetOr404(req, res);
+ if (!t) return;
+ const paths = [].concat(req.query.p ?? []).filter((p) => typeof p === "string");
+ if (!paths.length) {
+ return res.status(400).json({ error: { code: "EBADPATH", message: "name at least one path" } });
+ }
+ try {
+ // Sized and checked BEFORE the first byte, so a refusal is still JSON.
+ await fileExplorer.collect(t.cwd, paths);
+ } catch (err) {
+ return fsFail(res, err);
+ }
+ const base = paths.length === 1 ? path.posix.basename(paths[0]) : path.basename(t.cwd) || "files";
+ const name = `${base}.zip`;
+ const ascii = name.replace(/[^\x20-\x7e]|["\\]/g, "_");
+ res.set({
+ "Content-Type": "application/zip",
+ "Content-Disposition": `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`,
+ "Content-Security-Policy": "default-src 'none'; sandbox",
+ "X-Content-Type-Options": "nosniff",
+ "Cache-Control": "no-store",
+ });
+ fileExplorer.zip(t.cwd, paths, res).catch(() => res.destroy());
+});
+
/** Tracked paths matching `?q=`, for quick-open. */
router.get("/:id/git/ls", async (req, res) => {
const t = await gitTargetOr404(req, res);