feat: v2.7.7 — File Explorer on workspaces and the user's home, clone with per-user git identity
- File Explorer (Files in the sidebar, /files; also from a workspace's Files tab, portalled so no panel clips it), laid out like GNOME Files: places (Home, workspaces, Trash), Back/Forward/Up, path bar, search, icon grid with image thumbnails or list, labelled New/Upload, selection action bar, hidden files behind Ctrl+H, right-click menus and the usual keys. Make, rename, move, copy (within a workspace, between workspaces, home ↔ workspace), delete into a per-account trash restorable for 7 days, streamed upload of files and whole folders (≤ 500 MB a batch, Replace / Keep both / Skip on a taken name), download a file or a zip of anything. server/lib/file-explorer.js, /api/lanes/:id/fs/*, /api/homes/:who/fs/*. - The user's home: every dot entry at its top stays hidden and refused (also via a symlink, download, zip or as a copy source); an admin may view other users' homes read-only, audited. - write-as learns move/copy (re-run `sudo agenthub server install-system`). - Hardening: a write to a workspace re-checks its owner by the live role; S3 keeps its server.conf if the file stops reading and a tmux call never falls back to the host's server; the AI Proxy gateway marks what it relays and the dashboard refuses it; a NUL in a pane's argv/env is refused. - OpenAPI for the new routes (server/openapi-extra/files.js); docs (API, SERVER-MODE, CLAUDE.md) updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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() {
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="files"
|
||||
element={
|
||||
<Page>
|
||||
<FilesPage />
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="users"
|
||||
element={
|
||||
|
||||
@@ -56,6 +56,7 @@ import { useDialog } from "../hooks/useDialog";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
FolderOpen,
|
||||
HardDrive,
|
||||
BarChart3,
|
||||
Settings,
|
||||
Wifi,
|
||||
@@ -88,6 +89,8 @@ function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
|
||||
const NAV_KEYS = [
|
||||
{ to: "/", icon: LayoutDashboard, key: "nav:dashboard" },
|
||||
{ to: "/sessions", icon: FolderOpen, key: "nav:sessions" },
|
||||
// The file manager (FilesPage): browse, upload and download workspace files.
|
||||
{ to: "/files", icon: HardDrive, key: "nav:files" },
|
||||
] as const;
|
||||
|
||||
const STORAGE_KEY = "sidebar-collapsed";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{treeCtx.menu.item.dir && (
|
||||
<MenuItem
|
||||
testId="lane-changes-menu-explorer"
|
||||
onSelect={pick(treeCtx.close, () => setExplorer({ path: treeCtx.menu!.item.path }))}
|
||||
>
|
||||
{t("changes.openInExplorer")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem onSelect={pick(treeCtx.close, () => copy(treeCtx.menu!.item.path))}>
|
||||
{t("changes.copyPath")}
|
||||
</MenuItem>
|
||||
@@ -1888,7 +1897,11 @@ export function LaneChangesPanel({
|
||||
// upload only creates, into the folder it was asked for ("" = the root).
|
||||
const uploadInput = useRef<HTMLInputElement>(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"
|
||||
/>
|
||||
<IconButton label={t("changes.upload")} disabled={uploading} onClick={() => pickUpload("")}>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
<IconButton label={t("changes.openExplorer")} onClick={() => setExplorer({ path: "" })}>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
</IconButton>
|
||||
<FileExplorer
|
||||
open={!!explorer}
|
||||
laneId={laneId}
|
||||
session={session}
|
||||
source={source}
|
||||
initialPath={explorer?.path ?? ""}
|
||||
onChanged={() => (explorerChanged.current = true)}
|
||||
onClose={() => {
|
||||
setExplorer(null);
|
||||
if (explorerChanged.current) {
|
||||
explorerChanged.current = false;
|
||||
void rereadAll();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={uploadInput}
|
||||
type="file"
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* @file Tests for the workspace File Explorer modal: it lists a folder and
|
||||
* opens sub-folders, makes a folder inline, deletes into the Trash without a
|
||||
* confirm, copies in one workspace and pastes into another (`fromLane`),
|
||||
* asks Replace / Keep both / Skip when an upload's name is taken, refuses a
|
||||
* batch over 500 MB before sending anything, and restores from the Trash.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
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(<FileExplorer open laneId={1} onClose={vi.fn()} onChanged={vi.fn()} />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -85,5 +85,6 @@
|
||||
"closeMenu": "Close menu",
|
||||
"sidebar": "Sidebar",
|
||||
"now": "now",
|
||||
"users": "Users"
|
||||
"users": "Users",
|
||||
"files": "Files"
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -702,6 +702,8 @@ async function send<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
auth: body?.error?.auth as GitAuthNeeded | undefined,
|
||||
// ESESSIONLIVE from a workspace start: the pane already holding it.
|
||||
runId: body?.error?.run_id as string | undefined,
|
||||
// EEXISTS from the File Explorer: the names already taken.
|
||||
names: body?.error?.names as string[] | undefined,
|
||||
});
|
||||
}
|
||||
// A 200 that is not JSON — the SPA's index.html from a proxy or a dev server
|
||||
@@ -753,6 +755,54 @@ function gitUrl(target: GitTarget, sub: string, q?: URLSearchParams): string {
|
||||
return `/lanes/${lane}/git${sub}${qs ? `?${qs}` : ""}`;
|
||||
}
|
||||
|
||||
/** Where the File Explorer works: a workspace (as {@link GitTarget}) or a user's home. */
|
||||
export type FsPlace = GitTarget | { home: string };
|
||||
export const isHomePlace = (p: FsPlace): p is { home: string } =>
|
||||
typeof p === "object" && "home" in p;
|
||||
|
||||
/** `/lanes/:id/fs<sub>` (same target as {@link gitUrl}) or `/homes/:who/fs<sub>`. */
|
||||
function fsUrl(place: FsPlace, sub: string, q?: URLSearchParams): string {
|
||||
if (isHomePlace(place)) {
|
||||
const qs = new URLSearchParams(q).toString();
|
||||
return `/homes/${encodeURIComponent(place.home)}/fs${sub}${qs ? `?${qs}` : ""}`;
|
||||
}
|
||||
return gitUrl(place, sub, q).replace(/^(\/lanes\/\d+)\/git/, "$1/fs");
|
||||
}
|
||||
|
||||
/** A home the File Explorer can open (`GET /api/homes`): `me`, or another user's for an admin. */
|
||||
export interface FsHome {
|
||||
id: string;
|
||||
label: string;
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
/** One entry of a File Explorer folder listing (`GET /api/lanes/:id/fs/list`). */
|
||||
export interface FsEntry {
|
||||
name: string;
|
||||
/** Workspace-relative path. */
|
||||
path: string;
|
||||
/** `link` is a symlink; `linkdir` one to a folder inside the workspace. */
|
||||
type: "dir" | "file" | "link" | "linkdir" | "other";
|
||||
size: number | null;
|
||||
mtime: number;
|
||||
}
|
||||
export interface FsListing {
|
||||
path: string;
|
||||
parent: string | null;
|
||||
entries: FsEntry[];
|
||||
truncated: boolean;
|
||||
}
|
||||
export interface FsTrashItem {
|
||||
id: string;
|
||||
name: string;
|
||||
laneId: number | null;
|
||||
path: string;
|
||||
isDir: boolean;
|
||||
deletedAt: string;
|
||||
}
|
||||
/** How a taken name is settled: ask (409 EEXISTS with `names`), skip, keep both, replace. */
|
||||
export type FsConflict = "fail" | "skip" | "keep" | "replace";
|
||||
|
||||
/** The target for `laneId`: an extra folder's repo, else `session`'s worktree, else the workspace. */
|
||||
export function gitTarget(
|
||||
laneId: number,
|
||||
@@ -2443,6 +2493,123 @@ export const api = {
|
||||
{ method: "DELETE" }
|
||||
),
|
||||
},
|
||||
/**
|
||||
* The workspace File Explorer (`/api/lanes/:id/fs/*`, server/lib/file-explorer.js).
|
||||
* Every write runs as the folder's account; a taken name answers 409
|
||||
* EEXISTS with `names` unless `conflict` settles it.
|
||||
*/
|
||||
files: {
|
||||
/** GET /api/homes — the homes this user may open (their own; others' read-only for an admin). */
|
||||
homes: () => request<{ homes: FsHome[] }>("/homes"),
|
||||
/** One file's download URL, on a workspace or a home. */
|
||||
downloadUrl: (t: FsPlace, path: string) =>
|
||||
isHomePlace(t)
|
||||
? `${BASE}${fsUrl(t, "/download", new URLSearchParams({ path }))}`
|
||||
: `${BASE}${gitUrl(t, "/download", new URLSearchParams({ path }))}`,
|
||||
list: (t: FsPlace, path = "") =>
|
||||
request<FsListing>(fsUrl(t, "/list", new URLSearchParams({ path }))),
|
||||
mkdir: (t: FsPlace, path: string) =>
|
||||
request<{ path: string }>(fsUrl(t, "/mkdir"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ path }),
|
||||
}),
|
||||
newFile: (t: FsPlace, path: string) =>
|
||||
request<{ path: string }>(fsUrl(t, "/newfile"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ path }),
|
||||
}),
|
||||
rename: (t: FsPlace, path: string, name: string) =>
|
||||
request<{ path: string }>(fsUrl(t, "/rename"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ path, name }),
|
||||
}),
|
||||
move: (t: FsPlace, paths: string[], dest: string, conflict?: FsConflict) =>
|
||||
request<{ moved: string[] }>(fsUrl(t, "/move"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ paths, dest, conflict }),
|
||||
}),
|
||||
/** Copy into `t`'s `dest` — from `t` itself, or from workspace `fromLane`. */
|
||||
copy: (
|
||||
t: FsPlace,
|
||||
paths: string[],
|
||||
dest: string,
|
||||
opts: { fromLane?: number; fromHome?: string; conflict?: FsConflict } = {}
|
||||
) =>
|
||||
request<{ copied: string[] }>(fsUrl(t, "/copy"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ paths, dest, ...opts }),
|
||||
}),
|
||||
/** Into the account's trash (restorable). */
|
||||
remove: (t: FsPlace, paths: string[]) =>
|
||||
request<{ trashed: string[] }>(fsUrl(t, "/delete"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ paths }),
|
||||
}),
|
||||
trash: (t: FsPlace) =>
|
||||
request<{ items: FsTrashItem[]; keepDays?: number }>(fsUrl(t, "/trash")),
|
||||
restore: (t: FsPlace, id: string) =>
|
||||
request<{ path: string; laneId: number | null }>(fsUrl(t, "/trash/restore"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id }),
|
||||
}),
|
||||
purge: (t: FsPlace, id: string | "all") =>
|
||||
request<{ purged: number }>(fsUrl(t, "/trash/purge"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id }),
|
||||
}),
|
||||
/** A zip of `paths` (files and folders), for a plain link/anchor download. */
|
||||
zipUrl: (t: FsPlace, paths: string[]) => {
|
||||
const q = new URLSearchParams();
|
||||
for (const p of paths) q.append("p", p);
|
||||
return `${BASE}${fsUrl(t, "/zip", q)}`;
|
||||
},
|
||||
/**
|
||||
* Stream `file` to `path` (sub-folders made as needed), reporting upload
|
||||
* progress. Rejects like {@link request}: `code`, and `names` on EEXISTS.
|
||||
*/
|
||||
upload: (
|
||||
t: FsPlace,
|
||||
path: string,
|
||||
file: Blob,
|
||||
conflict: FsConflict = "fail",
|
||||
onProgress?: (sent: number) => void
|
||||
) =>
|
||||
new Promise<{ path: string; skipped?: boolean }>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", `${BASE}${fsUrl(t, "/upload", new URLSearchParams({ path, conflict }))}`);
|
||||
xhr.setRequestHeader("Content-Type", "application/octet-stream");
|
||||
const token = dashboardToken();
|
||||
if (token) xhr.setRequestHeader("x-dashboard-token", token);
|
||||
xhr.upload.onprogress = (e) => onProgress?.(e.loaded);
|
||||
xhr.onerror = () => reject(Object.assign(new Error("network error"), { code: "ENET" }));
|
||||
xhr.onload = () => {
|
||||
let body: { error?: { code?: string; message?: string; names?: string[] } } & {
|
||||
path?: string;
|
||||
skipped?: boolean;
|
||||
} = {};
|
||||
try {
|
||||
body = JSON.parse(xhr.responseText);
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(body as { path: string; skipped?: boolean });
|
||||
return;
|
||||
}
|
||||
if (xhr.status === 401 && body.error?.code === "EAUTH") {
|
||||
window.dispatchEvent(new Event(AUTH_REQUIRED_EVENT));
|
||||
}
|
||||
reject(
|
||||
Object.assign(new Error(body.error?.message || `HTTP ${xhr.status}`), {
|
||||
code: body.error?.code,
|
||||
status: xhr.status,
|
||||
names: body.error?.names,
|
||||
})
|
||||
);
|
||||
};
|
||||
xhr.send(file);
|
||||
}),
|
||||
},
|
||||
/** The signed-in user's own git identity (`/api/me/git`, `server/routes/git-identity.js`). */
|
||||
gitIdentity: {
|
||||
get: () => request<GitIdentity>("/me/git"),
|
||||
|
||||
@@ -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=<me|user>&path=` or `?lane=<id>&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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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<Start | null>(
|
||||
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 <p className="p-8 text-sm text-fg-muted">{t("fx.noWorkspace")}</p>;
|
||||
if (!start) return null;
|
||||
return (
|
||||
// Cancels Layout's page padding, like the Workspace session view.
|
||||
<div className="-m-5 flex h-[calc(100vh-1.5rem)] max-md:h-[calc(100vh-4.25rem)] lg:-m-6">
|
||||
<FileBrowser
|
||||
laneId={"lane" in start ? start.lane : null}
|
||||
initialHome={"home" in start ? start.home : null}
|
||||
initialPath={params.get("path") ?? ""}
|
||||
onNavigate={(place: FsPlace, path) => {
|
||||
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 });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7178,7 +7178,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.7.6 (rebuild client to match)
|
||||
UI build v2.7.7 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+55
@@ -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=<path>&p=… application/zip, streamed
|
||||
```
|
||||
|
||||
The workspace **File Explorer** — the **Files** page in the sidebar (`/files?lane=<id>&path=<folder>`), 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 `<data dir>/trash/<user id>/` — 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" | <username>, 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
|
||||
|
||||
```
|
||||
|
||||
@@ -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/<pid>/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. |
|
||||
|
||||
+918
-1
@@ -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:
|
||||
|
||||
Generated
+2
-2
@@ -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": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.7.6",
|
||||
"version": "2.7.7",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
set -eu
|
||||
if [ $# -lt 1 ] || [ $# -gt 2 ]; then
|
||||
echo "usage: write-as <absolute path> [append|replace|private|mkdir|remove]" >&2
|
||||
usage() {
|
||||
echo "usage: write-as <absolute path> [append|replace|private|mkdir|remove] | <path> move|copy <absolute dest>" >&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
|
||||
|
||||
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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));
|
||||
});
|
||||
});
|
||||
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
+14
-2
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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,
|
||||
};
|
||||
@@ -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/<name>`);
|
||||
* - 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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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" | <username>, 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 };
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 <file> <args…>` 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,
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+3
-1
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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 };
|
||||
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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;
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user