feat: v2.7.8 — Artifacts hub, clone related repos as extra folders, quieter resume prompt
- Artifacts (/artifacts): claude.ai pages sessions publish (captured from the Artifact tool's hook), pinned workspace files, skills and plugins, each kept as a snapshot; shared ones appear in a Hub and a skill or plugin installs into the viewer's own account (server/lib/artifacts.js, /api/artifacts, docs/plans/artifacts/). - Workspace extra folders: clone one or more related repos beside the workspace and add them in one step (POST /api/lanes/:id/sources/clone). - A pane that ended by itself while tmux kept running is no longer offered for resume (tmux.serverStartedAt in reconcileOrphans). - Changes panel: a folder that is not a git repo opens on Files, and its files open from there. - Plus the parallel sessions' work in this tree: File Explorer button in the panel header, user delete, RSA SSH keys, chat/terminal and pet fixes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+19
-2
@@ -64,6 +64,7 @@ import { GlobalTooltip } from "./components/Tooltip";
|
|||||||
import { Toaster } from "./components/Toaster";
|
import { Toaster } from "./components/Toaster";
|
||||||
import { ConfirmHost } from "./components/ConfirmHost";
|
import { ConfirmHost } from "./components/ConfirmHost";
|
||||||
import { RouteErrorBoundary, reloadOnce } from "./components/RouteErrorBoundary";
|
import { RouteErrorBoundary, reloadOnce } from "./components/RouteErrorBoundary";
|
||||||
|
import { usePermissions } from "./components/AuthGate";
|
||||||
import { NotFound } from "./pages/NotFound";
|
import { NotFound } from "./pages/NotFound";
|
||||||
import { useWebSocket } from "./hooks/useWebSocket";
|
import { useWebSocket } from "./hooks/useWebSocket";
|
||||||
import { useNotifications } from "./hooks/useNotifications";
|
import { useNotifications } from "./hooks/useNotifications";
|
||||||
@@ -89,6 +90,7 @@ const SnapshotView = lazy(() =>
|
|||||||
);
|
);
|
||||||
const FilesPage = lazy(() => import("./pages/FilesPage").then((m) => ({ default: m.FilesPage })));
|
const FilesPage = lazy(() => import("./pages/FilesPage").then((m) => ({ default: m.FilesPage })));
|
||||||
const Users = lazy(() => import("./pages/Users").then((m) => ({ default: m.Users })));
|
const Users = lazy(() => import("./pages/Users").then((m) => ({ default: m.Users })));
|
||||||
|
const Artifacts = lazy(() => import("./pages/Artifacts").then((m) => ({ default: m.Artifacts })));
|
||||||
|
|
||||||
function PageFallback() {
|
function PageFallback() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -108,6 +110,11 @@ function Page({ children }: { children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A page only a shared server needs (the File Explorer): local mode goes home. */
|
||||||
|
function ServerOnly({ children }: { children: ReactNode }) {
|
||||||
|
return usePermissions().local ? <Navigate to="/" replace /> : <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
/** Keyed by the id so moving to another session starts from fresh state. */
|
/** Keyed by the id so moving to another session starts from fresh state. */
|
||||||
function SessionDetailRoute() {
|
function SessionDetailRoute() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
@@ -212,13 +219,23 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="files"
|
path="artifacts"
|
||||||
element={
|
element={
|
||||||
<Page>
|
<Page>
|
||||||
<FilesPage />
|
<Artifacts />
|
||||||
</Page>
|
</Page>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="files"
|
||||||
|
element={
|
||||||
|
<ServerOnly>
|
||||||
|
<Page>
|
||||||
|
<FilesPage />
|
||||||
|
</Page>
|
||||||
|
</ServerOnly>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="users"
|
path="users"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ export function usePermissions() {
|
|||||||
const isAdmin = local || user?.role === "admin";
|
const isAdmin = local || user?.role === "admin";
|
||||||
return {
|
return {
|
||||||
isAdmin,
|
isAdmin,
|
||||||
|
// Local mode: one person on their own machine — what only serves a
|
||||||
|
// shared server (File Explorer, uploads, AgentHub's own git logins) is
|
||||||
|
// hidden; their desktop and their own git config already do it.
|
||||||
|
local,
|
||||||
seesAll: local || (!!user && user.role !== "developer"),
|
seesAll: local || (!!user && user.role !== "developer"),
|
||||||
// A developer only ever sees their own rows, so everything shown is theirs.
|
// A developer only ever sees their own rows, so everything shown is theirs.
|
||||||
canEditLane: (lane?: { owner_id?: number | null } | null) =>
|
canEditLane: (lane?: { owner_id?: number | null } | null) =>
|
||||||
|
|||||||
@@ -149,15 +149,21 @@ export function usePrefersReducedMotion(): boolean {
|
|||||||
* changes via ResizeObserver) so a tall panel near a screen edge slides fully
|
* changes via ResizeObserver) so a tall panel near a screen edge slides fully
|
||||||
* into view instead of being cropped.
|
* into view instead of being cropped.
|
||||||
*/
|
*/
|
||||||
/** `front`: above the mascot itself — the pet menu, which may overlap it. */
|
/**
|
||||||
|
* `front`: above the mascot itself — the pet menu, which may overlap it.
|
||||||
|
* `beside`: at the mascot's side instead of above it — the chat window, on the
|
||||||
|
* screen side away from the edge it docks to, its bottom level with the pet's.
|
||||||
|
*/
|
||||||
function SagiFlyout({
|
function SagiFlyout({
|
||||||
anchor,
|
anchor,
|
||||||
children,
|
children,
|
||||||
front = false,
|
front = false,
|
||||||
|
beside = false,
|
||||||
}: {
|
}: {
|
||||||
anchor: Anchor;
|
anchor: Anchor;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
front?: boolean;
|
front?: boolean;
|
||||||
|
beside?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const [style, setStyle] = useState<CSSProperties>({ visibility: "hidden" });
|
const [style, setStyle] = useState<CSSProperties>({ visibility: "hidden" });
|
||||||
@@ -170,6 +176,18 @@ function SagiFlyout({
|
|||||||
const vw = window.innerWidth;
|
const vw = window.innerWidth;
|
||||||
const vh = window.innerHeight;
|
const vh = window.innerHeight;
|
||||||
|
|
||||||
|
if (beside) {
|
||||||
|
let left =
|
||||||
|
anchor.side === "left"
|
||||||
|
? anchor.left + anchor.size + FLYOUT_GAP
|
||||||
|
: anchor.left - w - FLYOUT_GAP;
|
||||||
|
left = Math.min(vw - w - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, left));
|
||||||
|
let top = anchor.top + anchor.size - h;
|
||||||
|
top = Math.min(vh - h - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, top));
|
||||||
|
setStyle({ left, top, visibility: "visible" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Horizontal: hug the avatar's docked edge, then clamp on-screen.
|
// Horizontal: hug the avatar's docked edge, then clamp on-screen.
|
||||||
let left = anchor.side === "left" ? anchor.left : anchor.left + anchor.size - w;
|
let left = anchor.side === "left" ? anchor.left : anchor.left + anchor.size - w;
|
||||||
left = Math.min(vw - w - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, left));
|
left = Math.min(vw - w - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, left));
|
||||||
@@ -182,7 +200,7 @@ function SagiFlyout({
|
|||||||
top = Math.min(vh - h - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, top));
|
top = Math.min(vh - h - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, top));
|
||||||
|
|
||||||
setStyle({ left, top, visibility: "visible" });
|
setStyle({ left, top, visibility: "visible" });
|
||||||
}, [anchor.left, anchor.top, anchor.size, anchor.side, anchor.openUp]);
|
}, [anchor.left, anchor.top, anchor.size, anchor.side, anchor.openUp, beside]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
place();
|
place();
|
||||||
@@ -431,37 +449,32 @@ function SagiBody() {
|
|||||||
onClearAlerts={brain.clearAlerts}
|
onClearAlerts={brain.clearAlerts}
|
||||||
onNavigate={onNavigate}
|
onNavigate={onNavigate}
|
||||||
onAsk={onAsk}
|
onAsk={onAsk}
|
||||||
onOpenChat={() => {
|
|
||||||
setOpen(false);
|
|
||||||
setChatOpen(true);
|
|
||||||
}}
|
|
||||||
onClose={() => setOpen(false)}
|
onClose={() => setOpen(false)}
|
||||||
/>
|
/>
|
||||||
</SagiFlyout>
|
</SagiFlyout>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Kept mounted while dragging (only hidden): unmounting would detach
|
{/* Beside the pet, riding along with it mid-drag too (SagiFlyout follows
|
||||||
its terminal and lose a half-typed message. */}
|
it 1:1), and kept mounted: unmounting would detach its terminal and
|
||||||
|
lose a half-typed message. */}
|
||||||
{chatOpen && (
|
{chatOpen && (
|
||||||
<div style={place.dragging ? { display: "none" } : undefined}>
|
<SagiFlyout anchor={anchor} beside>
|
||||||
<SagiFlyout anchor={anchor}>
|
<PetChat
|
||||||
<PetChat
|
name={name}
|
||||||
name={name}
|
initialPrompt={chatPrompt}
|
||||||
initialPrompt={chatPrompt}
|
onPromptTaken={() => setChatPrompt(null)}
|
||||||
onPromptTaken={() => setChatPrompt(null)}
|
onBack={() => {
|
||||||
onBack={() => {
|
setChatOpen(false);
|
||||||
setChatOpen(false);
|
setOpen(true);
|
||||||
setOpen(true);
|
}}
|
||||||
}}
|
onClose={() => setChatOpen(false)}
|
||||||
onClose={() => setChatOpen(false)}
|
onOpenWorkspace={(laneId, runId) => {
|
||||||
onOpenWorkspace={(laneId, runId) => {
|
navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`);
|
||||||
navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`);
|
setChatOpen(false);
|
||||||
setChatOpen(false);
|
}}
|
||||||
}}
|
onBusy={onBusy}
|
||||||
onBusy={onBusy}
|
/>
|
||||||
/>
|
</SagiFlyout>
|
||||||
</SagiFlyout>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!place.dragging && menu && (
|
{!place.dragging && menu && (
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ import {
|
|||||||
Radio,
|
Radio,
|
||||||
Heart,
|
Heart,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
MessageCircle,
|
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -89,8 +88,6 @@ interface SagiPanelProps {
|
|||||||
onNavigate: (route: string) => void;
|
onNavigate: (route: string) => void;
|
||||||
/** Returns an answer to display, or null when the query was handed off. */
|
/** Returns an answer to display, or null when the query was handed off. */
|
||||||
onAsk: (query: string) => string | null;
|
onAsk: (query: string) => string | null;
|
||||||
/** Opens the chat window (PetChat). */
|
|
||||||
onOpenChat: () => void;
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +105,6 @@ export function SagiPanel({
|
|||||||
onClearAlerts,
|
onClearAlerts,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
onAsk,
|
onAsk,
|
||||||
onOpenChat,
|
|
||||||
onClose,
|
onClose,
|
||||||
}: SagiPanelProps) {
|
}: SagiPanelProps) {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
@@ -282,13 +278,6 @@ export function SagiPanel({
|
|||||||
|
|
||||||
{/* quick actions */}
|
{/* quick actions */}
|
||||||
<div className="mb-3 grid grid-cols-2 gap-1.5">
|
<div className="mb-3 grid grid-cols-2 gap-1.5">
|
||||||
<div className="col-span-2">
|
|
||||||
<ActionButton
|
|
||||||
icon={MessageCircle}
|
|
||||||
label={t("sagi.panel.chat", { name })}
|
|
||||||
onClick={onOpenChat}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon={muted ? BellOff : Bell}
|
icon={muted ? BellOff : Bell}
|
||||||
label={muted ? t("sagi.panel.unmute") : t("sagi.panel.mute")}
|
label={muted ? t("sagi.panel.unmute") : t("sagi.panel.mute")}
|
||||||
|
|||||||
@@ -144,10 +144,14 @@ describe("Sagi widget", () => {
|
|||||||
expect(screen.queryByRole("dialog", { name: /sagi companion/i })).not.toBeInTheDocument();
|
expect(screen.queryByRole("dialog", { name: /sagi companion/i })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens the chat from the panel and goes back to it", () => {
|
it("has no Chat button — Ask opens the chat — and Back returns to the panel", () => {
|
||||||
|
localStorage.setItem("agenthub.defaultTool", "claude");
|
||||||
renderSagi();
|
renderSagi();
|
||||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: /chat with sagi/i }));
|
expect(screen.queryByRole("button", { name: /chat with sagi/i })).not.toBeInTheDocument();
|
||||||
|
const input = screen.getByLabelText(/ask sagi/i);
|
||||||
|
fireEvent.change(input, { target: { value: "write me a haiku" } });
|
||||||
|
fireEvent.submit(input.closest("form")!);
|
||||||
expect(screen.getByRole("dialog", { name: /chat with sagi/i })).toBeInTheDocument();
|
expect(screen.getByRole("dialog", { name: /chat with sagi/i })).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Back" }));
|
fireEvent.click(screen.getByRole("button", { name: "Back" }));
|
||||||
expect(screen.getByRole("dialog", { name: /sagi companion/i })).toBeInTheDocument();
|
expect(screen.getByRole("dialog", { name: /sagi companion/i })).toBeInTheDocument();
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import {
|
|||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
|
Gem,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Settings,
|
Settings,
|
||||||
Wifi,
|
Wifi,
|
||||||
@@ -91,6 +92,8 @@ const NAV_KEYS = [
|
|||||||
{ to: "/sessions", icon: FolderOpen, key: "nav:sessions" },
|
{ to: "/sessions", icon: FolderOpen, key: "nav:sessions" },
|
||||||
// The file manager (FilesPage): browse, upload and download workspace files.
|
// The file manager (FilesPage): browse, upload and download workspace files.
|
||||||
{ to: "/files", icon: HardDrive, key: "nav:files" },
|
{ to: "/files", icon: HardDrive, key: "nav:files" },
|
||||||
|
// What agents produced, and (server mode) the Hub others shared (Artifacts.tsx).
|
||||||
|
{ to: "/artifacts", icon: Gem, key: "nav:artifacts" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const STORAGE_KEY = "sidebar-collapsed";
|
const STORAGE_KEY = "sidebar-collapsed";
|
||||||
@@ -197,7 +200,8 @@ export function Sidebar({
|
|||||||
location.pathname.startsWith("/settings") ||
|
location.pathname.startsWith("/settings") ||
|
||||||
new URLSearchParams(location.search).has("settings");
|
new URLSearchParams(location.search).has("settings");
|
||||||
// Checking for (and applying) an update acts on the host: an admin's.
|
// Checking for (and applying) an update acts on the host: an admin's.
|
||||||
const canUpdate = usePermissions().isAdmin;
|
const perms = usePermissions();
|
||||||
|
const canUpdate = perms.isAdmin;
|
||||||
const asideRef = useRef<HTMLElement>(null);
|
const asideRef = useRef<HTMLElement>(null);
|
||||||
const drawerOpen = Boolean(drawer?.open);
|
const drawerOpen = Boolean(drawer?.open);
|
||||||
useDialog(drawerOpen, asideRef, drawer?.onClose);
|
useDialog(drawerOpen, asideRef, drawer?.onClose);
|
||||||
@@ -472,7 +476,8 @@ export function Sidebar({
|
|||||||
<div className="flex-1 min-h-0 relative flex">
|
<div className="flex-1 min-h-0 relative flex">
|
||||||
<nav ref={navRef} className="flex-1 overflow-y-auto overflow-x-hidden px-2 py-2 space-y-px">
|
<nav ref={navRef} className="flex-1 overflow-y-auto overflow-x-hidden px-2 py-2 space-y-px">
|
||||||
{[
|
{[
|
||||||
...NAV_KEYS,
|
// The file manager serves a shared server; locally it is the desktop's.
|
||||||
|
...NAV_KEYS.filter((n) => !(perms.local && n.to === "/files")),
|
||||||
// Server mode: the Users page, for an admin or analyst.
|
// Server mode: the Users page, for an admin or analyst.
|
||||||
...(auth.state.mode === "server" &&
|
...(auth.state.mode === "server" &&
|
||||||
auth.state.user &&
|
auth.state.user &&
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ describe("Sidebar", () => {
|
|||||||
expect(hrefs).toContain("/sessions");
|
expect(hrefs).toContain("/sessions");
|
||||||
expect(hrefs).toContain("/settings");
|
expect(hrefs).toContain("/settings");
|
||||||
expect(hrefs).not.toContain("/activity");
|
expect(hrefs).not.toContain("/activity");
|
||||||
|
// The file manager serves a shared server; a local dashboard has none.
|
||||||
|
expect(hrefs).not.toContain("/files");
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("update status line, in the connection details modal", () => {
|
describe("update status line, in the connection details modal", () => {
|
||||||
|
|||||||
@@ -4,9 +4,11 @@
|
|||||||
* subset of CommonMark + GFM that actually appears in Claude Code transcripts:
|
* subset of CommonMark + GFM that actually appears in Claude Code transcripts:
|
||||||
* fenced code blocks, ATX headings, ordered/unordered lists, task lists, blockquotes,
|
* fenced code blocks, ATX headings, ordered/unordered lists, task lists, blockquotes,
|
||||||
* horizontal rules, simple tables, inline code, bold, italic, strikethrough, links,
|
* horizontal rules, simple tables, inline code, bold, italic, strikethrough, links,
|
||||||
* and auto-linked URLs. An absolute file path (bare or in inline code) opens
|
* and auto-linked URLs. A file path — absolute, or relative with a folder and
|
||||||
* through `OpenFileContext` when the page provides one; `linkify` does the
|
* an extension (`server/lib/x.js`), with or without a `:line` suffix, bare, in
|
||||||
* same links for plain text. To make a reply easy to scan, bold is coloured
|
* inline code or as a markdown link's target — opens through `OpenFileContext`
|
||||||
|
* when the page provides one (the page resolves a relative one against its own
|
||||||
|
* folder); `linkify` does the same links for plain text. To make a reply easy to scan, bold is coloured
|
||||||
* (accent), standalone numbers (87%, 3.5s, v2.7.1, dates) are tinted and
|
* (accent), standalone numbers (87%, 3.5s, v2.7.1, dates) are tinted and
|
||||||
* tabular, and a flag label ("Lưu ý:", "Warning:", "Lỗi:") takes the colour
|
* tabular, and a flag label ("Lưu ý:", "Warning:", "Lỗi:") takes the colour
|
||||||
* of how serious it is.
|
* of how serious it is.
|
||||||
@@ -256,7 +258,19 @@ const LINK_CLASS =
|
|||||||
"text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70 break-all";
|
"text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70 break-all";
|
||||||
const URL_RE = /^https?:\/\/[^\s<>()]+[^\s<>().,!?;:'"]/;
|
const URL_RE = /^https?:\/\/[^\s<>()]+[^\s<>().,!?;:'"]/;
|
||||||
// Two segments at least, so "/" or "/s" stays text; a trailing full stop is prose.
|
// Two segments at least, so "/" or "/s" stays text; a trailing full stop is prose.
|
||||||
const PATH_RE = /^(?:\/[^\s/<>()'"`,;:]*[^\s/<>()'"`,;:.!?]){2,}/;
|
// A `:12` or `:12:5` line suffix belongs to the link (Claude writes `path:line`).
|
||||||
|
const PATH_RE = /^(?:\/[^\s/<>()'"`,;:]*[^\s/<>()'"`,;:.!?]){2,}(?::\d+(?::\d+)?)?/;
|
||||||
|
// A relative path needs a folder AND an extension starting with a letter
|
||||||
|
// (`docs/API.md`, `./a/b.ts:9`), so "and/or" and "3/4.5" stay text; a first segment with an inner dot is a host
|
||||||
|
// (`github.com/x.js`), not a folder — a leading one (`.claude/…`) is a folder.
|
||||||
|
const REL_PATH_RE =
|
||||||
|
/^(?:\.{1,2}\/)*(?:\.?[\w@-][\w@-]*\/)(?:[\w.@-]+\/)*[\w.@-]*[\w@-]\.[A-Za-z][A-Za-z0-9]{0,9}(?::\d+(?::\d+)?)?(?![\p{L}\p{N}_/])/u;
|
||||||
|
/** A path at the start of `text`, absolute or relative, or null. */
|
||||||
|
const matchPath = (text: string) => PATH_RE.exec(text)?.[0] ?? REL_PATH_RE.exec(text)?.[0] ?? null;
|
||||||
|
/** `a/b.ts:12:5` → `a/b.ts`: what is opened. */
|
||||||
|
export const stripLine = (p: string) => p.replace(/(?::\d+){1,2}$/, "");
|
||||||
|
// A markdown link target that is a page, not a file.
|
||||||
|
const SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
|
||||||
// A link starts a word: "and/or" or "a/b/c" stays text.
|
// A link starts a word: "and/or" or "a/b/c" stays text.
|
||||||
const atWordStart = (text: string, i: number) => i === 0 || /[\s([{"'`]/.test(text[i - 1]!);
|
const atWordStart = (text: string, i: number) => i === 0 || /[\s([{"'`]/.test(text[i - 1]!);
|
||||||
// A number worth spotting: 42, 1,234.5, 87%, $1.20, 3.5s, 12MB, v2.7.1, 2026-10-12, 12:30,
|
// A number worth spotting: 42, 1,234.5, 87%, $1.20, 3.5s, 12MB, v2.7.1, 2026-10-12, 12:30,
|
||||||
@@ -284,6 +298,17 @@ function UrlLink({ url }: { url: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A markdown link's file target as a path: `%20` decoded, `#L12` → `:12`. */
|
||||||
|
function decodeHref(href: string): string {
|
||||||
|
let p = href.replace(/^file:\/\//i, "").replace(/#L(\d+)(?:-L?\d+)?$/, ":$1").replace(/#.*$/, "");
|
||||||
|
try {
|
||||||
|
p = decodeURIComponent(p);
|
||||||
|
} catch {
|
||||||
|
/* a stray % stays as written */
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
/** A path as a button that opens it, or as it was when nothing can. */
|
/** A path as a button that opens it, or as it was when nothing can. */
|
||||||
function PathLink({ path, children }: { path: string; children: React.ReactNode }) {
|
function PathLink({ path, children }: { path: string; children: React.ReactNode }) {
|
||||||
const open = React.useContext(OpenFileContext);
|
const open = React.useContext(OpenFileContext);
|
||||||
@@ -292,7 +317,7 @@ function PathLink({ path, children }: { path: string; children: React.ReactNode
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title={path}
|
title={path}
|
||||||
onClick={() => open(path)}
|
onClick={() => open(stripLine(path))}
|
||||||
className={`${LINK_CLASS} text-left`}
|
className={`${LINK_CLASS} text-left`}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -307,7 +332,7 @@ export function linkify(text: string): React.ReactNode[] {
|
|||||||
for (let i = 0; i < text.length;) {
|
for (let i = 0; i < text.length;) {
|
||||||
const rest = text.slice(i);
|
const rest = text.slice(i);
|
||||||
const url = atWordStart(text, i) && URL_RE.exec(rest)?.[0];
|
const url = atWordStart(text, i) && URL_RE.exec(rest)?.[0];
|
||||||
const path = !url && atWordStart(text, i) && PATH_RE.exec(rest)?.[0];
|
const path = !url && atWordStart(text, i) && matchPath(rest);
|
||||||
const hit = url || path;
|
const hit = url || path;
|
||||||
if (!hit) {
|
if (!hit) {
|
||||||
buf += text[i++];
|
buf += text[i++];
|
||||||
@@ -359,7 +384,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
|||||||
</code>
|
</code>
|
||||||
);
|
);
|
||||||
push(
|
push(
|
||||||
PATH_RE.exec(codeM[1]!)?.[0] === codeM[1] ? (
|
matchPath(codeM[1]!) === codeM[1] ? (
|
||||||
<PathLink path={codeM[1]!}>{code}</PathLink>
|
<PathLink path={codeM[1]!}>{code}</PathLink>
|
||||||
) : (
|
) : (
|
||||||
code
|
code
|
||||||
@@ -408,9 +433,23 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
|||||||
// Markdown link
|
// Markdown link
|
||||||
const linkM = rest.match(/^\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/);
|
const linkM = rest.match(/^\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/);
|
||||||
if (linkM) {
|
if (linkM) {
|
||||||
|
const href = linkM[2]!;
|
||||||
|
// `[x.ts](src/x.ts#L12)` names a file: open it like a bare path.
|
||||||
|
if (
|
||||||
|
/^file:\/\//i.test(href) ||
|
||||||
|
(!SCHEME_RE.test(href) && !href.startsWith("#") && !href.startsWith("//"))
|
||||||
|
) {
|
||||||
|
push(
|
||||||
|
<PathLink path={decodeHref(href)}>
|
||||||
|
{renderInline(linkM[1]!, `${baseKey}-l${n}`)}
|
||||||
|
</PathLink>
|
||||||
|
);
|
||||||
|
i += linkM[0].length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
push(
|
push(
|
||||||
<a
|
<a
|
||||||
href={linkM[2]!}
|
href={href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70"
|
className="text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70"
|
||||||
@@ -431,10 +470,10 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// An absolute path at a word start
|
// An absolute path at a word start
|
||||||
const pathM = atWordStart(text, i) ? PATH_RE.exec(rest) : null;
|
const pathM = atWordStart(text, i) ? matchPath(rest) : null;
|
||||||
if (pathM) {
|
if (pathM) {
|
||||||
push(<PathLink path={pathM[0]}>{pathM[0]}</PathLink>);
|
push(<PathLink path={pathM}>{pathM}</PathLink>);
|
||||||
i += pathM[0].length;
|
i += pathM.length;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,52 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { vi } from "vitest";
|
||||||
import { MarkdownContent } from "../MarkdownContent";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { MarkdownContent, OpenFileContext } from "../MarkdownContent";
|
||||||
|
import { resolveIn } from "../../../lib/resolvePath";
|
||||||
|
|
||||||
|
function withOpen(text: string) {
|
||||||
|
const open = vi.fn();
|
||||||
|
render(
|
||||||
|
<OpenFileContext.Provider value={open}>
|
||||||
|
<MarkdownContent text={text} />
|
||||||
|
</OpenFileContext.Provider>
|
||||||
|
);
|
||||||
|
return open;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("file links", () => {
|
||||||
|
it("opens a relative path with its line suffix stripped", () => {
|
||||||
|
const open = withOpen("See server/lib/x.js:42 for it.");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "server/lib/x.js:42" }));
|
||||||
|
expect(open).toHaveBeenCalledWith("server/lib/x.js");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens an absolute path with a line suffix and one in inline code", () => {
|
||||||
|
const open = withOpen("At /a/b/c.ts:9 and `docs/API.md`.");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "/a/b/c.ts:9" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "docs/API.md" }));
|
||||||
|
expect(open.mock.calls).toEqual([["/a/b/c.ts"], ["docs/API.md"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens a markdown link to a file instead of navigating", () => {
|
||||||
|
const open = withOpen("[the panel](client/src/A%20B.tsx#L12) and [web](https://x.dev)");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "the panel" }));
|
||||||
|
expect(open).toHaveBeenCalledWith("client/src/A B.tsx");
|
||||||
|
expect(screen.getByRole("link", { name: "web" })).toHaveAttribute("href", "https://x.dev");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves prose with slashes as text", () => {
|
||||||
|
withOpen("and/or, 3/4.5, github.com/x.js, TCP/IP.");
|
||||||
|
expect(screen.queryAllByRole("button")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves a relative path against the pane's folder", () => {
|
||||||
|
expect(resolveIn("/w/repo/", "./server/../docs/API.md")).toBe("/w/repo/docs/API.md");
|
||||||
|
expect(resolveIn("/w/repo", "/etc/x.conf")).toBe("/etc/x.conf");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("<MarkdownContent />", () => {
|
describe("<MarkdownContent />", () => {
|
||||||
it("renders fenced code blocks with the language label", () => {
|
it("renders fenced code blocks with the language label", () => {
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
/**
|
||||||
|
* @file CloneSourcesForm — a workspace's "Or clone repositories" box, under
|
||||||
|
* its Folders card: one URL per line, each cloned BESIDE the workspace folder
|
||||||
|
* (`POST /api/lanes/:id/sources/clone`, the server picks the folder) and
|
||||||
|
* added as an extra folder, one after another. A host that wants a login
|
||||||
|
* (409 `ECREDENTIALS`) opens one login row; Retry re-runs only the failed
|
||||||
|
* URLs with it, like Add workspace → Clone.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Check, Loader2, X } from "lucide-react";
|
||||||
|
import { api, type LaneSourcesView } from "../../lib/api";
|
||||||
|
|
||||||
|
type Row = { url: string; state: "wait" | "busy" | "done" | "failed"; note?: string };
|
||||||
|
type Login = { host: string; username: string; token: string; remember: boolean };
|
||||||
|
|
||||||
|
export function CloneSourcesForm({
|
||||||
|
laneId,
|
||||||
|
parent,
|
||||||
|
onAdded,
|
||||||
|
}: {
|
||||||
|
laneId: number;
|
||||||
|
/** Where the clones land: the workspace folder's parent, shown only. */
|
||||||
|
parent: string;
|
||||||
|
onAdded: (view: LaneSourcesView) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("lanes");
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const [rows, setRows] = useState<Row[]>([]);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [login, setLogin] = useState<Login | null>(null);
|
||||||
|
|
||||||
|
const urls = (s: string) => [
|
||||||
|
...new Set(
|
||||||
|
s
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
const cloneAll = async (list: string[]) => {
|
||||||
|
if (busy || !list.length) return;
|
||||||
|
setBusy(true);
|
||||||
|
const next: Row[] = list.map((url) => ({ url, state: "wait" }));
|
||||||
|
const show = () => setRows([...next]);
|
||||||
|
show();
|
||||||
|
let asked: Login | null = null;
|
||||||
|
for (const row of next) {
|
||||||
|
row.state = "busy";
|
||||||
|
show();
|
||||||
|
try {
|
||||||
|
const view = await api.lanes.cloneFolder(laneId, {
|
||||||
|
url: row.url,
|
||||||
|
...(login?.username.trim() && login.token
|
||||||
|
? {
|
||||||
|
credentials: {
|
||||||
|
username: login.username.trim(),
|
||||||
|
password: login.token,
|
||||||
|
remember: login.remember,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
row.state = "done";
|
||||||
|
onAdded(view);
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as { code?: string; auth?: { host?: string }; message?: string };
|
||||||
|
const host = e.auth?.host || row.url;
|
||||||
|
row.state = "failed";
|
||||||
|
if (e.code === "ECREDENTIALS") {
|
||||||
|
row.note = t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host });
|
||||||
|
asked ??= {
|
||||||
|
host,
|
||||||
|
username: login?.username ?? "",
|
||||||
|
token: "",
|
||||||
|
remember: login?.remember ?? true,
|
||||||
|
};
|
||||||
|
} else if (e.code === "ESSHAUTH") {
|
||||||
|
row.note = t("clone.sshRefused", { host });
|
||||||
|
} else {
|
||||||
|
row.note = e.message || String(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
show();
|
||||||
|
}
|
||||||
|
if (asked) setLogin(asked);
|
||||||
|
// What is left to do stays in the box; what was added leaves it.
|
||||||
|
setText(
|
||||||
|
next
|
||||||
|
.filter((r) => r.state !== "done")
|
||||||
|
.map((r) => r.url)
|
||||||
|
.join("\n")
|
||||||
|
);
|
||||||
|
setBusy(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const failed = rows.filter((r) => r.state === "failed").map((r) => r.url);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 border-t border-border-light pt-2" data-testid="workspace-clone">
|
||||||
|
<p className="text-[10px] uppercase tracking-wide text-fg-muted">{t("home.cloneFolders")}</p>
|
||||||
|
<textarea
|
||||||
|
data-testid="workspace-clone-input"
|
||||||
|
aria-label={t("home.cloneFolders")}
|
||||||
|
placeholder={t("home.clonePlaceholder")}
|
||||||
|
rows={3}
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-fg-muted">{t("home.cloneHint", { path: parent })}</p>
|
||||||
|
{login && (
|
||||||
|
<div className="space-y-1.5 rounded border border-border-light p-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
aria-label={t("clone.username")}
|
||||||
|
placeholder={t("clone.username")}
|
||||||
|
value={login.username}
|
||||||
|
onChange={(e) => setLogin({ ...login, username: e.target.value })}
|
||||||
|
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 text-[11px] text-fg-primary"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
aria-label={t("clone.token")}
|
||||||
|
placeholder={t("clone.token")}
|
||||||
|
value={login.token}
|
||||||
|
onChange={(e) => setLogin({ ...login, token: e.target.value })}
|
||||||
|
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 text-[11px] text-fg-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-1.5 text-[11px] text-fg-secondary">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={login.remember}
|
||||||
|
onChange={(e) => setLogin({ ...login, remember: e.target.checked })}
|
||||||
|
/>
|
||||||
|
{t("clone.remember")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
{failed.length > 0 && !busy && (
|
||||||
|
<button type="button" onClick={() => void cloneAll(failed)} className="btn-ghost text-xs">
|
||||||
|
{t("home.cloneRetry")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="workspace-clone-run"
|
||||||
|
disabled={busy || !urls(text).length}
|
||||||
|
onClick={() => void cloneAll(urls(text))}
|
||||||
|
className="btn-ghost text-xs"
|
||||||
|
>
|
||||||
|
{busy ? t("clone.cloning") : t("home.cloneRun")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{rows.length > 0 && (
|
||||||
|
<ul className="space-y-1" data-testid="workspace-clone-rows">
|
||||||
|
{rows.map((r) => (
|
||||||
|
<li key={r.url} className="text-[11px]">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
{r.state === "busy" ? (
|
||||||
|
<Loader2 className="h-3 w-3 shrink-0 animate-spin text-fg-muted" />
|
||||||
|
) : r.state === "done" ? (
|
||||||
|
<Check
|
||||||
|
className="h-3 w-3 shrink-0 text-status-success"
|
||||||
|
aria-label={t("home.cloneDone")}
|
||||||
|
/>
|
||||||
|
) : r.state === "failed" ? (
|
||||||
|
<X className="h-3 w-3 shrink-0 text-status-danger" />
|
||||||
|
) : (
|
||||||
|
<span className="h-3 w-3 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="min-w-0 truncate font-mono text-fg-secondary" title={r.url}>
|
||||||
|
{r.url}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{r.note && <span className="ml-[18px] block text-status-danger">{r.note}</span>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -270,9 +270,13 @@ export function FileBrowser({
|
|||||||
const folderInput = useRef<HTMLInputElement>(null);
|
const folderInput = useRef<HTMLInputElement>(null);
|
||||||
const ctx = useContextMenu<FsEntry | null>();
|
const ctx = useContextMenu<FsEntry | null>();
|
||||||
|
|
||||||
// A new caller target (another workspace's Files tab, the page's URL).
|
// A new caller target (another workspace's Files tab, the page's URL) —
|
||||||
|
// not the echo of our own `go()` through the page's URL, which would wipe
|
||||||
|
// Back's history on every folder opened.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTarget(placeOf());
|
const next = placeOf();
|
||||||
|
if (samePlace(next, target) && initialPath === path) return;
|
||||||
|
setTarget(next);
|
||||||
setPathState(initialPath);
|
setPathState(initialPath);
|
||||||
setHistory({ back: [], fwd: [] });
|
setHistory({ back: [], fwd: [] });
|
||||||
setSelected(new Set());
|
setSelected(new Set());
|
||||||
@@ -1145,7 +1149,13 @@ export function FileBrowser({
|
|||||||
{listing && !entries.length && !creating && !error && (
|
{listing && !entries.length && !creating && !error && (
|
||||||
<div className="flex flex-col items-center justify-center gap-2 py-16 text-fg-muted">
|
<div className="flex flex-col items-center justify-center gap-2 py-16 text-fg-muted">
|
||||||
<Folder className="h-16 w-16 opacity-40" />
|
<Folder className="h-16 w-16 opacity-40" />
|
||||||
<p className="text-sm">{query ? t("fx.noMatch") : t("fx.empty")}</p>
|
<p className="max-w-md text-center text-sm">
|
||||||
|
{query
|
||||||
|
? t("fx.noMatch")
|
||||||
|
: isHomePlace(target) && !path
|
||||||
|
? t("fx.homeEmpty")
|
||||||
|
: t("fx.empty")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{loading && !listing && <p className="p-6 text-sm text-fg-muted">{t("fx.loading")}</p>}
|
{loading && !listing && <p className="p-6 text-sm text-fg-muted">{t("fx.loading")}</p>}
|
||||||
|
|||||||
@@ -102,9 +102,9 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
Trash2,
|
Trash2,
|
||||||
UnfoldVertical,
|
UnfoldVertical,
|
||||||
FolderOpen,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api, gitTarget } from "../../lib/api";
|
import { api, gitTarget } from "../../lib/api";
|
||||||
|
import { usePermissions } from "../AuthGate";
|
||||||
import { pasteToLane } from "../../lib/panePaste";
|
import { pasteToLane } from "../../lib/panePaste";
|
||||||
import { confirmDialog, confirmDiscard as askDiscard } from "../../lib/confirm";
|
import { confirmDialog, confirmDiscard as askDiscard } from "../../lib/confirm";
|
||||||
import { copyText } from "../../lib/clipboard";
|
import { copyText } from "../../lib/clipboard";
|
||||||
@@ -810,8 +810,12 @@ export function LaneChangesPanel({
|
|||||||
wide = full,
|
wide = full,
|
||||||
onDirtyChange,
|
onDirtyChange,
|
||||||
openRequest = null,
|
openRequest = null,
|
||||||
|
filesChanged = 0,
|
||||||
}: {
|
}: {
|
||||||
laneId: number;
|
laneId: number;
|
||||||
|
/** Bumped when files changed outside this panel (the header's File
|
||||||
|
* Explorer): the tree is read again, its open folders kept open. */
|
||||||
|
filesChanged?: number;
|
||||||
/** Open this file (relative to the panel's folder); a new `nonce` asks again. */
|
/** Open this file (relative to the panel's folder); a new `nonce` asks again. */
|
||||||
openRequest?: { path: string; nonce: number } | null;
|
openRequest?: { path: string; nonce: number } | null;
|
||||||
/** The whole-file editor holds unsaved text (true) or not (false). */
|
/** The whole-file editor holds unsaved text (true) or not (false). */
|
||||||
@@ -855,6 +859,9 @@ export function LaneChangesPanel({
|
|||||||
// history and the tree too, not just the diff `load` re-reads.
|
// history and the tree too, not just the diff `load` re-reads.
|
||||||
const [reread, setReread] = useState(0);
|
const [reread, setReread] = useState(0);
|
||||||
const [range, setRange] = useState<LaneChangeRange>("work");
|
const [range, setRange] = useState<LaneChangeRange>("work");
|
||||||
|
// Local mode: the files are already on this machine — no upload, download
|
||||||
|
// or File Explorer (the desktop's file manager does all three).
|
||||||
|
const { local } = usePermissions();
|
||||||
const [mode, setMode] = useState<Mode>("overview");
|
const [mode, setMode] = useState<Mode>("overview");
|
||||||
const [openFile, setOpenFile] = useState<string | null>(null);
|
const [openFile, setOpenFile] = useState<string | null>(null);
|
||||||
// An image opens as a picture, never as text or a binary diff.
|
// An image opens as a picture, never as text or a binary diff.
|
||||||
@@ -870,6 +877,16 @@ export function LaneChangesPanel({
|
|||||||
const [mdPreview, setMdPreview] = useState(false);
|
const [mdPreview, setMdPreview] = useState(false);
|
||||||
useEffect(() => setMdPreview(false), [openFile]);
|
useEffect(() => setMdPreview(false), [openFile]);
|
||||||
const [data, setData] = useState<LaneChanges | null>(null);
|
const [data, setData] = useState<LaneChanges | null>(null);
|
||||||
|
// A folder that is not a repo (an empty new workspace, a parent of clones)
|
||||||
|
// has no changes, review or history — only its files, which Files reads
|
||||||
|
// from the disk. Open there instead of on a page that only says "no git".
|
||||||
|
// Both reads must say so: the facts alone also read "unavailable" on a
|
||||||
|
// network error or an older server.
|
||||||
|
const notRepo = git?.available === false && data?.available === false;
|
||||||
|
useEffect(() => {
|
||||||
|
if (notRepo && (mode === "overview" || mode === "review" || mode === "history"))
|
||||||
|
setMode(full ? "overview" : "browse");
|
||||||
|
}, [notRepo, mode, full]);
|
||||||
const [commits, setCommits] = useState<LaneCommit[] | null>(null);
|
const [commits, setCommits] = useState<LaneCommit[] | null>(null);
|
||||||
// A folder's children, keyed by its path ("" is the root) — fetched once,
|
// A folder's children, keyed by its path ("" is the root) — fetched once,
|
||||||
// on first expand, and kept so re-collapsing doesn't re-fetch.
|
// on first expand, and kept so re-collapsing doesn't re-fetch.
|
||||||
@@ -1078,19 +1095,32 @@ export function LaneChangesPanel({
|
|||||||
}, [mode, laneId, query, reread, full, treeRootLoaded]);
|
}, [mode, laneId, query, reread, full, treeRootLoaded]);
|
||||||
|
|
||||||
// A folder's children are fetched lazily, the first time it is expanded.
|
// A folder's children are fetched lazily, the first time it is expanded.
|
||||||
|
const readDir = (path: string) =>
|
||||||
|
api.lanes
|
||||||
|
.gitTree(target, path)
|
||||||
|
.then(forLane(laneId, (r) => setTreeChildren((prev) => ({ ...prev, [path]: r.entries }))))
|
||||||
|
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||||
const toggleDir = (path: string) => {
|
const toggleDir = (path: string) => {
|
||||||
|
const opening = !expandedDirs.has(path);
|
||||||
setExpandedDirs((prev) => {
|
setExpandedDirs((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (!next.delete(path)) next.add(path);
|
if (!next.delete(path)) next.add(path);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
if (path in treeChildren) return;
|
// Every OPEN reads the folder again (the cached level shows meanwhile):
|
||||||
void api.lanes
|
// files land in it from outside the panel — an agent, the terminal, the
|
||||||
.gitTree(target, path)
|
// File Explorer — and a level cached while it was empty stayed empty.
|
||||||
.then(forLane(laneId, (r) => setTreeChildren((prev) => ({ ...prev, [path]: r.entries }))))
|
if (opening) void readDir(path);
|
||||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Files changed outside the panel: read the root and every open folder again.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!filesChanged) return;
|
||||||
|
void readDir("");
|
||||||
|
for (const p of expandedDirs) void readDir(p);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [filesChanged]);
|
||||||
|
|
||||||
const [treeBusy, setTreeBusy] = useState(false);
|
const [treeBusy, setTreeBusy] = useState(false);
|
||||||
|
|
||||||
// "Expand all" is ONE read of the whole tree: a request per folder meant a
|
// "Expand all" is ONE read of the whole tree: a request per folder meant a
|
||||||
@@ -1624,15 +1654,17 @@ export function LaneChangesPanel({
|
|||||||
<MenuItem onSelect={pick(treeCtx.close, () => showHistory(treeCtx.menu!.item.path))}>
|
<MenuItem onSelect={pick(treeCtx.close, () => showHistory(treeCtx.menu!.item.path))}>
|
||||||
{t("changes.showHistory")}
|
{t("changes.showHistory")}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem
|
{!local && (
|
||||||
testId="lane-changes-menu-download"
|
<MenuItem
|
||||||
onSelect={pick(treeCtx.close, () => download(treeCtx.menu!.item.path))}
|
testId="lane-changes-menu-download"
|
||||||
>
|
onSelect={pick(treeCtx.close, () => download(treeCtx.menu!.item.path))}
|
||||||
{t("changes.download")}
|
>
|
||||||
</MenuItem>
|
{t("changes.download")}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{treeCtx.menu.item.dir && (
|
{!local && treeCtx.menu.item.dir && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
testId="lane-changes-menu-upload"
|
testId="lane-changes-menu-upload"
|
||||||
onSelect={pick(treeCtx.close, () => pickUpload(treeCtx.menu!.item.path))}
|
onSelect={pick(treeCtx.close, () => pickUpload(treeCtx.menu!.item.path))}
|
||||||
@@ -1640,7 +1672,7 @@ export function LaneChangesPanel({
|
|||||||
{t("changes.uploadHere")}
|
{t("changes.uploadHere")}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
{treeCtx.menu.item.dir && (
|
{!local && treeCtx.menu.item.dir && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
testId="lane-changes-menu-explorer"
|
testId="lane-changes-menu-explorer"
|
||||||
onSelect={pick(treeCtx.close, () => setExplorer({ path: treeCtx.menu!.item.path }))}
|
onSelect={pick(treeCtx.close, () => setExplorer({ path: treeCtx.menu!.item.path }))}
|
||||||
@@ -2134,9 +2166,9 @@ export function LaneChangesPanel({
|
|||||||
aria-label={t("changes.quickOpen")}
|
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"
|
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.openExplorer")} onClick={() => setExplorer({ path: "" })}>
|
{/* The File Explorer's button lives in the panel header now (beside
|
||||||
<FolderOpen className="h-3.5 w-3.5" />
|
Full screen); this instance serves the tree's "Open in File
|
||||||
</IconButton>
|
Explorer". */}
|
||||||
<FileExplorer
|
<FileExplorer
|
||||||
open={!!explorer}
|
open={!!explorer}
|
||||||
laneId={laneId}
|
laneId={laneId}
|
||||||
@@ -2265,14 +2297,16 @@ export function LaneChangesPanel({
|
|||||||
<ChevronLeft className="h-3 w-3" />
|
<ChevronLeft className="h-3 w-3" />
|
||||||
{t("changes.back")}
|
{t("changes.back")}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : notRepo && full ? null : (
|
||||||
<div className="flex min-w-0 items-center rounded border border-border bg-surface-2 p-0.5">
|
<div className="flex min-w-0 items-center rounded border border-border bg-surface-2 p-0.5">
|
||||||
{/* Files is the whole repo's tree; at full width it is the
|
{/* Files is the whole repo's tree; at full width it is the
|
||||||
sidebar already, so no button. */}
|
sidebar already, so no button. */}
|
||||||
{(full
|
{(notRepo
|
||||||
? (["overview", "review", "history"] as const)
|
? (["browse"] as const)
|
||||||
: (["overview", "browse", "review", "history"] as const)
|
: full
|
||||||
).map((m) => (
|
? (["overview", "review", "history"] as const)
|
||||||
|
: (["overview", "browse", "review", "history"] as const)
|
||||||
|
).map((m: Mode) => (
|
||||||
<button
|
<button
|
||||||
key={m}
|
key={m}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -2627,7 +2661,9 @@ export function LaneChangesPanel({
|
|||||||
browsePane
|
browsePane
|
||||||
) : !data ? (
|
) : !data ? (
|
||||||
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
|
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
|
||||||
) : !data.available ? (
|
) : !data.available && mode !== "file" ? (
|
||||||
|
// A file still opens from Files: its whole-file read is the disk's,
|
||||||
|
// not git's.
|
||||||
<p className="text-[11px] text-fg-muted">
|
<p className="text-[11px] text-fg-muted">
|
||||||
{t(source ? "changes.unavailableFolder" : "changes.unavailable")}
|
{t(source ? "changes.unavailableFolder" : "changes.unavailable")}
|
||||||
</p>
|
</p>
|
||||||
@@ -2878,7 +2914,7 @@ export function LaneChangesPanel({
|
|||||||
{mdPreview ? t("changes.raw") : t("changes.preview")}
|
{mdPreview ? t("changes.raw") : t("changes.preview")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{range !== "commit" && openFile && (
|
{!local && range !== "commit" && openFile && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="lane-file-download"
|
data-testid="lane-file-download"
|
||||||
@@ -2970,7 +3006,9 @@ export function LaneChangesPanel({
|
|||||||
collapse={!whole}
|
collapse={!whole}
|
||||||
revertLabel={t("changes.revertBlock")}
|
revertLabel={t("changes.revertBlock")}
|
||||||
value={draft}
|
value={draft}
|
||||||
original={baseText ?? draft}
|
// Outside a repo there is no HEAD: the edits are
|
||||||
|
// shown against the text as it was opened.
|
||||||
|
original={(notRepo ? text?.lines.join("\n") : baseText) ?? draft}
|
||||||
lang={langFor(openFile ?? "")}
|
lang={langFor(openFile ?? "")}
|
||||||
label={t("changes.editorLabel", { path: openFile })}
|
label={t("changes.editorLabel", { path: openFile })}
|
||||||
onChange={setDraft}
|
onChange={setDraft}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
* /api/lanes/:id/sources…`, see server/lib/lane-sources.js). Shown as the
|
* /api/lanes/:id/sources…`, see server/lib/lane-sources.js). Shown as the
|
||||||
* workspace home's right column and, `compact`, folded at the bottom of the
|
* workspace home's right column and, `compact`, folded at the bottom of the
|
||||||
* session view's Changes panel. Folder suggestions are the repo's untracked
|
* session view's Changes panel. Folder suggestions are the repo's untracked
|
||||||
* worktrees from `GET /api/projects`, filtered by Settings → Worktrees.
|
* worktrees from `GET /api/projects`, filtered by Settings → Worktrees; the
|
||||||
|
* same form clones related repos beside the workspace (`CloneSourcesForm`).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
|
|||||||
import { MarkdownPreview } from "../MarkdownPreview";
|
import { MarkdownPreview } from "../MarkdownPreview";
|
||||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||||
import { PathPickButton } from "../PathPickButton";
|
import { PathPickButton } from "../PathPickButton";
|
||||||
|
import { CloneSourcesForm } from "./CloneSourcesForm";
|
||||||
|
|
||||||
function fmtBytes(n: number | null): string {
|
function fmtBytes(n: number | null): string {
|
||||||
if (!n) return "";
|
if (!n) return "";
|
||||||
@@ -436,6 +438,16 @@ function ContextBody({
|
|||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
{folderOpen && (
|
||||||
|
<CloneSourcesForm
|
||||||
|
laneId={lane.id}
|
||||||
|
parent={lane.cwd.replace(/\/+$/, "").replace(/\/[^/]*$/, "") || "/"}
|
||||||
|
onAdded={(next) => {
|
||||||
|
setView(next);
|
||||||
|
onSourcesChange?.(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests for CloneSourcesForm: every URL in the box is cloned in turn as
|
||||||
|
* an extra folder, a host that wants a login opens the login row, and Retry
|
||||||
|
* re-runs only the failed URLs with that login.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import "../../../i18n";
|
||||||
|
|
||||||
|
vi.mock("../../../lib/api", () => ({
|
||||||
|
api: { lanes: { cloneFolder: vi.fn() } },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { api, type LaneSourcesView } from "../../../lib/api";
|
||||||
|
import { CloneSourcesForm } from "../CloneSourcesForm";
|
||||||
|
|
||||||
|
const view = { sources: [] } as unknown as LaneSourcesView & { path: string };
|
||||||
|
|
||||||
|
describe("CloneSourcesForm", () => {
|
||||||
|
beforeEach(() => vi.mocked(api.lanes.cloneFolder).mockReset());
|
||||||
|
|
||||||
|
it("clones each URL, asks for a login where needed, and retries only that one", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onAdded = vi.fn();
|
||||||
|
vi.mocked(api.lanes.cloneFolder).mockImplementation(async (_id, body) => {
|
||||||
|
if (body?.url.includes("private") && !body.credentials)
|
||||||
|
throw Object.assign(new Error("login"), {
|
||||||
|
code: "ECREDENTIALS",
|
||||||
|
auth: { protocol: "https", host: "private.test" },
|
||||||
|
});
|
||||||
|
return view;
|
||||||
|
});
|
||||||
|
render(<CloneSourcesForm laneId={3} parent="/w" onAdded={onAdded} />);
|
||||||
|
|
||||||
|
await user.type(
|
||||||
|
screen.getByTestId("workspace-clone-input"),
|
||||||
|
"https://h/a.git\nhttps://private.test/b.git\nhttps://h/a.git"
|
||||||
|
);
|
||||||
|
await user.click(screen.getByTestId("workspace-clone-run"));
|
||||||
|
|
||||||
|
await waitFor(() => expect(api.lanes.cloneFolder).toHaveBeenCalledTimes(2));
|
||||||
|
expect(onAdded).toHaveBeenCalledTimes(1);
|
||||||
|
expect(await screen.findByText(/private\.test needs a login/)).toBeInTheDocument();
|
||||||
|
// Only what is left stays in the box.
|
||||||
|
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("https://private.test/b.git");
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText("Username"), "dev");
|
||||||
|
await user.type(screen.getByLabelText("Password or access token"), "tok");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Retry the failed ones" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(2));
|
||||||
|
expect(api.lanes.cloneFolder).toHaveBeenLastCalledWith(3, {
|
||||||
|
url: "https://private.test/b.git",
|
||||||
|
credentials: { username: "dev", password: "tok", remember: true },
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,6 +24,12 @@ import type { LaneChanges } from "../../../lib/types";
|
|||||||
|
|
||||||
// The themed confirm needs its host (App); answer it here.
|
// The themed confirm needs its host (App); answer it here.
|
||||||
// CodeMirror does not run in jsdom: the editor is a textarea over the same props.
|
// CodeMirror does not run in jsdom: the editor is a textarea over the same props.
|
||||||
|
// Local mode unless a test says otherwise.
|
||||||
|
const perms = vi.hoisted(() => ({ local: true }));
|
||||||
|
vi.mock("../../AuthGate", async (importOriginal) => {
|
||||||
|
const m = await importOriginal<typeof import("../../AuthGate")>();
|
||||||
|
return { ...m, usePermissions: () => ({ ...m.usePermissions(), local: perms.local }) };
|
||||||
|
});
|
||||||
vi.mock("../DiffEditor", () => ({
|
vi.mock("../DiffEditor", () => ({
|
||||||
default: (p: {
|
default: (p: {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -146,6 +152,7 @@ function discardOf(path: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
perms.local = true;
|
||||||
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
|
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
|
||||||
vi.mocked(api.lanes.changes).mockReset();
|
vi.mocked(api.lanes.changes).mockReset();
|
||||||
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
|
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
|
||||||
@@ -310,16 +317,29 @@ describe("LaneChangesPanel", () => {
|
|||||||
await waitFor(() => expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "branch" }));
|
await waitFor(() => expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "branch" }));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("says so, quietly, when the lane's directory is not a git repo", async () => {
|
it("says so, quietly, beside the tree at full width, when the folder is not a repo", async () => {
|
||||||
|
vi.mocked(api.lanes.changes).mockResolvedValue({
|
||||||
|
available: false,
|
||||||
|
range: "work",
|
||||||
|
files: [],
|
||||||
|
});
|
||||||
|
render(<LaneChangesPanel laneId={7} full />);
|
||||||
|
expect(
|
||||||
|
await screen.findByText("This workspace's directory is not a readable git repository.")
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens on Files, its only tab, when the workspace folder is not a repo", async () => {
|
||||||
vi.mocked(api.lanes.changes).mockResolvedValue({
|
vi.mocked(api.lanes.changes).mockResolvedValue({
|
||||||
available: false,
|
available: false,
|
||||||
range: "work",
|
range: "work",
|
||||||
files: [],
|
files: [],
|
||||||
});
|
});
|
||||||
render(<LaneChangesPanel laneId={7} />);
|
render(<LaneChangesPanel laneId={7} />);
|
||||||
expect(
|
expect(await screen.findByTestId("lane-changes-browse")).toBeInTheDocument();
|
||||||
await screen.findByText("This workspace's directory is not a readable git repository.")
|
expect(screen.getByRole("button", { name: "Files" })).toHaveAttribute("aria-pressed", "true");
|
||||||
).toBeInTheDocument();
|
for (const name of ["Changes", "Review", "History"])
|
||||||
|
expect(screen.queryByRole("button", { name })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adds an unversioned file to VCS, for that path alone, and ticks it", async () => {
|
it("adds an unversioned file to VCS, for that path alone, and ticks it", async () => {
|
||||||
@@ -548,7 +568,38 @@ describe("LaneChangesPanel", () => {
|
|||||||
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
|
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reads a folder again on every open, so files added since show (not a stale empty level)", async () => {
|
||||||
|
let filled = false;
|
||||||
|
vi.mocked(api.lanes.gitTree).mockImplementation(async (_t, path = "") => ({
|
||||||
|
available: true,
|
||||||
|
path,
|
||||||
|
entries:
|
||||||
|
path === "src"
|
||||||
|
? filled
|
||||||
|
? [{ name: "app.ts", path: "src/app.ts", dir: false }]
|
||||||
|
: []
|
||||||
|
: [{ name: "src", path: "src", dir: true }],
|
||||||
|
truncated: false,
|
||||||
|
}));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { rerender } = render(<LaneChangesPanel laneId={7} full />);
|
||||||
|
const sidebar = await screen.findByTestId("lane-changes-sidebar");
|
||||||
|
await user.click(await within(sidebar).findByText("src"));
|
||||||
|
await waitFor(() => expect(api.lanes.gitTree).toHaveBeenCalledWith(7, "src"));
|
||||||
|
expect(within(sidebar).queryByText("app.ts")).toBeNull();
|
||||||
|
// Source arrives from outside the panel (the File Explorer, a terminal).
|
||||||
|
filled = true;
|
||||||
|
await user.click(within(sidebar).getByText("src")); // close
|
||||||
|
await user.click(within(sidebar).getByText("src")); // open again → read again
|
||||||
|
expect(await within(sidebar).findByText("app.ts")).toBeInTheDocument();
|
||||||
|
// And the header's File Explorer reports a change: open folders re-read.
|
||||||
|
filled = false;
|
||||||
|
rerender(<LaneChangesPanel laneId={7} full filesChanged={1} />);
|
||||||
|
await waitFor(() => expect(within(sidebar).queryByText("app.ts")).toBeNull());
|
||||||
|
});
|
||||||
|
|
||||||
it("uploads files into a folder from the tree menu, and downloads a file (issue #78)", async () => {
|
it("uploads files into a folder from the tree menu, and downloads a file (issue #78)", async () => {
|
||||||
|
perms.local = false;
|
||||||
vi.mocked(api.lanes.gitTree).mockImplementation(async (_t, path = "") => ({
|
vi.mocked(api.lanes.gitTree).mockImplementation(async (_t, path = "") => ({
|
||||||
available: true,
|
available: true,
|
||||||
path,
|
path,
|
||||||
@@ -586,6 +637,25 @@ describe("LaneChangesPanel", () => {
|
|||||||
picked.mockRestore();
|
picked.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("offers no upload, download or File Explorer in local mode", async () => {
|
||||||
|
vi.mocked(api.lanes.gitTree).mockImplementation(async (_t, path = "") => ({
|
||||||
|
available: true,
|
||||||
|
path,
|
||||||
|
entries:
|
||||||
|
path === "out"
|
||||||
|
? [{ name: "report.md", path: "out/report.md", dir: false }]
|
||||||
|
: [{ name: "out", path: "out", dir: true }],
|
||||||
|
truncated: false,
|
||||||
|
}));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<LaneChangesPanel laneId={7} full />);
|
||||||
|
const sidebar = await screen.findByTestId("lane-changes-sidebar");
|
||||||
|
await user.pointer({ keys: "[MouseRight]", target: await within(sidebar).findByText("out") });
|
||||||
|
expect(screen.getByTestId("lane-changes-tree-menu")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("lane-changes-menu-upload")).toBeNull();
|
||||||
|
expect(screen.queryByTestId("lane-changes-menu-explorer")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("at full width shows the tree as a sidebar and opens a file straight in the editor", async () => {
|
it("at full width shows the tree as a sidebar and opens a file straight in the editor", async () => {
|
||||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||||
available: true,
|
available: true,
|
||||||
@@ -1728,6 +1798,24 @@ describe("LaneChangesPanel", () => {
|
|||||||
expect(screen.getByTestId("lane-file-view")).toBeInTheDocument();
|
expect(screen.getByTestId("lane-file-view")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens a file from Files when the workspace folder is not a repo", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
vi.mocked(api.lanes.changes).mockResolvedValue({
|
||||||
|
available: false,
|
||||||
|
range: "work",
|
||||||
|
files: [],
|
||||||
|
});
|
||||||
|
render(<Tabbed laneId={7} />);
|
||||||
|
await screen.findByTestId("lane-changes-browse");
|
||||||
|
|
||||||
|
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
|
||||||
|
await user.click(await screen.findByText("src/main.ts"));
|
||||||
|
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 1;");
|
||||||
|
expect(
|
||||||
|
screen.queryByText("This workspace's directory is not a readable git repository.")
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps the overview rows mounted across re-renders (typing in the commit box)", async () => {
|
it("keeps the overview rows mounted across re-renders (typing in the commit box)", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<LaneChangesPanel laneId={7} />);
|
render(<LaneChangesPanel laneId={7} />);
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
* `~/.claude` (a memory file, a plugin's skill) through `GET /api/cc-config/file`,
|
* `~/.claude` (a memory file, a plugin's skill) through `GET /api/cc-config/file`,
|
||||||
* an attached image or text file through `GET /api/run/drop/:name`, an image
|
* an attached image or text file through `GET /api/run/drop/:name`, an image
|
||||||
* the session read anywhere else through `GET /api/sessions/:id/image`, and a
|
* the session read anywhere else through `GET /api/sessions/:id/image`, and a
|
||||||
* text file it read or wrote anywhere else through `GET /api/sessions/:id/file`.
|
* text file it read, wrote or named (a generated report) anywhere else through
|
||||||
|
* `GET /api/sessions/:id/file`.
|
||||||
* Anything else (or a file those routes refuse) shows its path only. A Windows path's `\` counts as `/` throughout.
|
* Anything else (or a file those routes refuse) shows its path only. A Windows path's `\` counts as `/` throughout.
|
||||||
* `SkillListModal` is the same dialog over the session's skills, each one's
|
* `SkillListModal` is the same dialog over the session's skills, each one's
|
||||||
* SKILL.md found by name (`plugin:name` from the plugin list, else the user's
|
* SKILL.md found by name (`plugin:name` from the plugin list, else the user's
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ import { addAttachments, dragAttachments } from "../../lib/promptAttach";
|
|||||||
import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
|
import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
|
||||||
import { ChatOutline, outlineAttachment, useSessionOutline, type OutlineList } from "./ChatOutline";
|
import { ChatOutline, outlineAttachment, useSessionOutline, type OutlineList } from "./ChatOutline";
|
||||||
import { FileListModal, FilePreview, SkillListModal, type PreviewScope } from "./ChatFilePreview";
|
import { FileListModal, FilePreview, SkillListModal, type PreviewScope } from "./ChatFilePreview";
|
||||||
|
import { resolveIn } from "../../lib/resolvePath";
|
||||||
import { Select } from "../Select";
|
import { Select } from "../Select";
|
||||||
import { Divider, showTerminal } from "./PaneGrid";
|
import { Divider, showTerminal } from "./PaneGrid";
|
||||||
import {
|
import {
|
||||||
@@ -471,7 +472,9 @@ export const PaneChat = memo(function PaneChat({
|
|||||||
// (ConversationView is memoized).
|
// (ConversationView is memoized).
|
||||||
const openTerminal = useCallback(() => showTerminal(runId, sessionId), [runId, sessionId]);
|
const openTerminal = useCallback(() => showTerminal(runId, sessionId), [runId, sessionId]);
|
||||||
const openFile = useCallback(
|
const openFile = useCallback(
|
||||||
(path: string) => {
|
(named: string) => {
|
||||||
|
// A relative path in a reply (`server/lib/x.js`) is relative to this pane's folder.
|
||||||
|
const path = resolveIn(cwd, named);
|
||||||
// The workspace's Changes panel when the file is in its folder;
|
// The workspace's Changes panel when the file is in its folder;
|
||||||
// else this view's own preview.
|
// else this view's own preview.
|
||||||
if (laneId !== null && openInChanges(laneId, cwd, path)) return;
|
if (laneId !== null && openInChanges(laneId, cwd, path)) return;
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ import {
|
|||||||
addAttachments,
|
addAttachments,
|
||||||
droppedAttachments,
|
droppedAttachments,
|
||||||
droppedSkill,
|
droppedSkill,
|
||||||
|
droppedText,
|
||||||
SKILL_MIME,
|
SKILL_MIME,
|
||||||
rememberUploads,
|
rememberUploads,
|
||||||
} from "../../lib/promptAttach";
|
} from "../../lib/promptAttach";
|
||||||
@@ -412,7 +413,8 @@ export function PromptEditor({
|
|||||||
// else a `bare` editor's parent, the box every caller draws around it and
|
// else a `bare` editor's parent, the box every caller draws around it and
|
||||||
// its toolbar — not only on the textarea: a panel row (`ATTACH_MIME`)
|
// its toolbar — not only on the textarea: a panel row (`ATTACH_MIME`)
|
||||||
// becomes an `@path` chip, a skill (`SKILL_MIME`) the prompt's leading
|
// becomes an `@path` chip, a skill (`SKILL_MIME`) the prompt's leading
|
||||||
// `/command`, a file is uploaded.
|
// `/command`, a file is uploaded, a local `file://` list an `@path` chip
|
||||||
|
// and any other text is inserted.
|
||||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||||
const dropRef = useRef<(e: DragEvent) => void>(() => {});
|
const dropRef = useRef<(e: DragEvent) => void>(() => {});
|
||||||
dropRef.current = (e) => {
|
dropRef.current = (e) => {
|
||||||
@@ -425,6 +427,17 @@ export function PromptEditor({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onChange(`/${skill} ${value.replace(/^\s+/, "")}`);
|
onChange(`/${skill} ${value.replace(/^\s+/, "")}`);
|
||||||
} else if (attachImages(e.dataTransfer?.files)) e.preventDefault();
|
} else if (attachImages(e.dataTransfer?.files)) e.preventDefault();
|
||||||
|
else {
|
||||||
|
// Only text (a file manager's or an editor's `file://` list, a
|
||||||
|
// selection, a link): what the terminal beneath would paste.
|
||||||
|
// On the textarea itself the browser inserts text at the drop point.
|
||||||
|
const dropped = droppedText(e.dataTransfer);
|
||||||
|
if (!dropped || ("text" in dropped && e.target === taRef.current)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
if ("attachments" in dropped)
|
||||||
|
onImagesChange((prev) => addAttachments(prev, dropped.attachments));
|
||||||
|
else insert(dropped.text);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const zone =
|
const zone =
|
||||||
@@ -433,7 +446,8 @@ export function PromptEditor({
|
|||||||
if (!zone) return;
|
if (!zone) return;
|
||||||
const over = (e: DragEvent) => {
|
const over = (e: DragEvent) => {
|
||||||
const types = e.dataTransfer?.types ?? [];
|
const types = e.dataTransfer?.types ?? [];
|
||||||
if (![ATTACH_MIME, SKILL_MIME, "Files"].some((m) => types.includes(m))) return;
|
const accepts = [ATTACH_MIME, SKILL_MIME, "Files", "text/uri-list", "text/plain"];
|
||||||
|
if (!accepts.some((m) => types.includes(m))) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.dataTransfer!.dropEffect = "copy";
|
e.dataTransfer!.dropEffect = "copy";
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -406,6 +406,16 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
}, 0);
|
}, 0);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// Ctrl+Z: a terminal sends ^Z (SIGTSTP), which SUSPENDS claude or
|
||||||
|
// codex — the pane drops to its shell and the session looks closed,
|
||||||
|
// with nothing on screen saying `fg` brings it back. In a browser
|
||||||
|
// Ctrl+Z means undo, so it sends Ctrl+_ — undo in Claude Code's prompt
|
||||||
|
// and in bash/readline — and never suspends the program.
|
||||||
|
if (ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey && key === "z") {
|
||||||
|
ev.preventDefault();
|
||||||
|
term.input("\x1f", true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (ev.key === "Tab" && tabMovesRef.current) return false;
|
if (ev.key === "Tab" && tabMovesRef.current) return false;
|
||||||
if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
|
if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -434,6 +434,45 @@ describe("PaneChat — the unsent prompt", () => {
|
|||||||
await waitFor(() => expect(localStorage.getItem("agenthub.chat.draft.s1")).toBeNull());
|
await waitFor(() => expect(localStorage.getItem("agenthub.chat.draft.s1")).toBeNull());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A drag from outside the dashboard (a file manager, VS Code, a selection)
|
||||||
|
// carries only text: the terminal beneath pastes it, so the chat takes it too.
|
||||||
|
const textDrop = (data: Record<string, string>) => {
|
||||||
|
const drop = new Event("drop", { bubbles: true, cancelable: true });
|
||||||
|
Object.defineProperty(drop, "dataTransfer", {
|
||||||
|
value: { types: Object.keys(data), getData: (t: string) => data[t] ?? "", files: [] },
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
screen.getByTestId("pane-chat").dispatchEvent(drop);
|
||||||
|
});
|
||||||
|
return drop;
|
||||||
|
};
|
||||||
|
|
||||||
|
it("attaches the local files of a dropped file:// list", async () => {
|
||||||
|
renderChat();
|
||||||
|
const drop = textDrop({
|
||||||
|
"text/uri-list": "file:///home/u/My%20Docs/spec.md\r\nfile:///home/u/b.ts",
|
||||||
|
"text/plain": "/home/u/My Docs/spec.md",
|
||||||
|
});
|
||||||
|
expect(drop.defaultPrevented).toBe(true);
|
||||||
|
expect(await screen.findByText("spec.md")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("b.ts")).toBeInTheDocument();
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("Write a message…"), {
|
||||||
|
target: { value: "read" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByTestId("pane-chat-send"));
|
||||||
|
expect(submitToRun).toHaveBeenCalledWith(
|
||||||
|
"agenthub-lane-1",
|
||||||
|
'read @"/home/u/My Docs/spec.md" @/home/u/b.ts'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inserts dropped plain text into the prompt", () => {
|
||||||
|
renderChat();
|
||||||
|
const drop = textDrop({ "text/plain": "src/lib/x.ts" });
|
||||||
|
expect(drop.defaultPrevented).toBe(true);
|
||||||
|
expect(screen.getByPlaceholderText("Write a message…")).toHaveValue("src/lib/x.ts");
|
||||||
|
});
|
||||||
|
|
||||||
// Issue #73: before the session starts the CLI may be asking in the terminal
|
// Issue #73: before the session starts the CLI may be asking in the terminal
|
||||||
// (trust this folder? — "No, exit" preselected); an Enter sent from here
|
// (trust this folder? — "No, exit" preselected); an Enter sent from here
|
||||||
// would answer it, so a send asks first.
|
// would answer it, so a send asks first.
|
||||||
|
|||||||
@@ -520,9 +520,15 @@ describe("TerminalView", () => {
|
|||||||
"application/x-agenthub-attach": '{"path":"@src/a.ts","name":"a.ts"}',
|
"application/x-agenthub-attach": '{"path":"@src/a.ts","name":"a.ts"}',
|
||||||
"text/plain": "src/a.ts",
|
"text/plain": "src/a.ts",
|
||||||
};
|
};
|
||||||
const dataTransfer = { types: Object.keys(data), files: [], getData: (k: string) => data[k] ?? "" };
|
const dataTransfer = {
|
||||||
|
types: Object.keys(data),
|
||||||
|
files: [],
|
||||||
|
getData: (k: string) => data[k] ?? "",
|
||||||
|
};
|
||||||
fireEvent.dragEnter(screen.getByTestId("terminal-view"), { dataTransfer });
|
fireEvent.dragEnter(screen.getByTestId("terminal-view"), { dataTransfer });
|
||||||
expect(screen.getByTestId("terminal-drop")).toHaveTextContent("Drop to paste into the terminal");
|
expect(screen.getByTestId("terminal-drop")).toHaveTextContent(
|
||||||
|
"Drop to paste into the terminal"
|
||||||
|
);
|
||||||
fireEvent.drop(screen.getByTestId("terminal-view"), { dataTransfer });
|
fireEvent.drop(screen.getByTestId("terminal-view"), { dataTransfer });
|
||||||
expect(pasteMock).toHaveBeenCalledWith("src/a.ts ");
|
expect(pasteMock).toHaveBeenCalledWith("src/a.ts ");
|
||||||
expect(screen.queryByTestId("terminal-drop")).toBeNull();
|
expect(screen.queryByTestId("terminal-drop")).toBeNull();
|
||||||
@@ -582,6 +588,24 @@ describe("TerminalView", () => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ^Z suspended claude: the pane fell to its shell and the session looked
|
||||||
|
// closed. Ctrl+Z is undo in a browser, so it sends Ctrl+_ instead.
|
||||||
|
it("turns Ctrl+Z into undo (Ctrl+_) and never sends ^Z, which would suspend the session", () => {
|
||||||
|
inputMock.mockClear();
|
||||||
|
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||||
|
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
|
||||||
|
const ev = new KeyboardEvent("keydown", { key: "z", ctrlKey: true, cancelable: true });
|
||||||
|
expect(handler(ev)).toBe(false);
|
||||||
|
expect(ev.defaultPrevented).toBe(true);
|
||||||
|
expect(inputMock).toHaveBeenCalledWith("\x1f", true);
|
||||||
|
expect(inputMock).not.toHaveBeenCalledWith("\x1a", expect.anything());
|
||||||
|
// Other chords with Z are left to xterm (Ctrl+Shift+Z, plain z).
|
||||||
|
expect(handler(new KeyboardEvent("keydown", { key: "Z", ctrlKey: true, shiftKey: true }))).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
expect(handler(new KeyboardEvent("keydown", { key: "z" }))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
// Safari + macOS's own Vietnamese Telex: the first letter is an insertText,
|
// Safari + macOS's own Vietnamese Telex: the first letter is an insertText,
|
||||||
// every later key REPLACES the word — the event order captured on a Mac.
|
// every later key REPLACES the word — the event order captured on a Mac.
|
||||||
it("forwards Safari Telex's word replacements as backspaces plus the new tail", () => {
|
it("forwards Safari Telex's word replacements as backspaces plus the new tail", () => {
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ export function GitIdentitySettings() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ed25519 suits GitHub/GitLab/Gitea; AWS IAM (CodeCommit) accepts only RSA.
|
||||||
|
const [keyType, setKeyType] = useState<"ed25519" | "rsa">("ed25519");
|
||||||
const generate = async () => {
|
const generate = async () => {
|
||||||
if (
|
if (
|
||||||
id?.ssh &&
|
id?.ssh &&
|
||||||
@@ -73,7 +75,10 @@ export function GitIdentitySettings() {
|
|||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
await run(() => api.gitIdentity.generateKey(Boolean(id?.ssh)), t("gitIdentity.keyMade"));
|
await run(
|
||||||
|
() => api.gitIdentity.generateKey(Boolean(id?.ssh), keyType),
|
||||||
|
t("gitIdentity.keyMade")
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeKey = async () => {
|
const removeKey = async () => {
|
||||||
@@ -203,6 +208,18 @@ export function GitIdentitySettings() {
|
|||||||
<h3 className="flex-1 text-xs font-semibold text-fg-primary">
|
<h3 className="flex-1 text-xs font-semibold text-fg-primary">
|
||||||
{t("gitIdentity.sshTitle")}
|
{t("gitIdentity.sshTitle")}
|
||||||
</h3>
|
</h3>
|
||||||
|
{id.writable && (
|
||||||
|
<select
|
||||||
|
aria-label={t("gitIdentity.keyType")}
|
||||||
|
value={keyType}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => setKeyType(e.target.value === "rsa" ? "rsa" : "ed25519")}
|
||||||
|
className="select !py-1 !text-xs"
|
||||||
|
>
|
||||||
|
<option value="ed25519">{t("gitIdentity.keyEd25519")}</option>
|
||||||
|
<option value="rsa">{t("gitIdentity.keyRsa")}</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
{id.writable && (
|
{id.writable && (
|
||||||
<button type="button" className={BTN} disabled={busy} onClick={() => void generate()}>
|
<button type="button" className={BTN} disabled={busy} onClick={() => void generate()}>
|
||||||
{id.ssh ? <RefreshCw className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
{id.ssh ? <RefreshCw className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
||||||
@@ -222,6 +239,7 @@ export function GitIdentitySettings() {
|
|||||||
/>
|
/>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="min-w-0 flex-1 truncate font-mono text-[10px] text-fg-muted">
|
<span className="min-w-0 flex-1 truncate font-mono text-[10px] text-fg-muted">
|
||||||
|
{id.ssh.type === "rsa" ? "RSA 4096 · " : id.ssh.type ? "ED25519 · " : ""}
|
||||||
{id.ssh.fingerprint}
|
{id.ssh.fingerprint}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ describe("GitIdentitySettings", () => {
|
|||||||
render(<GitIdentitySettings />);
|
render(<GitIdentitySettings />);
|
||||||
await userEvent.click(await screen.findByRole("button", { name: "Generate SSH key" }));
|
await userEvent.click(await screen.findByRole("button", { name: "Generate SSH key" }));
|
||||||
expect(confirmDialog).not.toHaveBeenCalled();
|
expect(confirmDialog).not.toHaveBeenCalled();
|
||||||
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false);
|
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "ed25519");
|
||||||
expect(await screen.findByLabelText("Public key")).toHaveValue(KEY.publicKey);
|
expect(await screen.findByLabelText("Public key")).toHaveValue(KEY.publicKey);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ describe("GitIdentitySettings", () => {
|
|||||||
expect(copyText).toHaveBeenCalledWith(KEY.publicKey);
|
expect(copyText).toHaveBeenCalledWith(KEY.publicKey);
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Replace key" }));
|
await userEvent.click(screen.getByRole("button", { name: "Replace key" }));
|
||||||
expect(confirmDialog).toHaveBeenCalled();
|
expect(confirmDialog).toHaveBeenCalled();
|
||||||
await waitFor(() => expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(true));
|
await waitFor(() => expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(true, "ed25519"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lets a member save an https login of their own", async () => {
|
it("lets a member save an https login of their own", async () => {
|
||||||
@@ -85,6 +85,18 @@ describe("GitIdentitySettings", () => {
|
|||||||
expect(await screen.findByText("github.com")).toBeInTheDocument();
|
expect(await screen.findByText("github.com")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("makes an RSA key when that type is picked (AWS CodeCommit takes only RSA)", async () => {
|
||||||
|
vi.mocked(api.gitIdentity.get).mockResolvedValue(view());
|
||||||
|
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(
|
||||||
|
view({ ssh: { ...KEY, type: "rsa", publicKey: "ssh-rsa AAAAB3 an@agenthub" } })
|
||||||
|
);
|
||||||
|
render(<GitIdentitySettings />);
|
||||||
|
await userEvent.selectOptions(await screen.findByLabelText("Key type"), "rsa");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Generate SSH key" }));
|
||||||
|
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa");
|
||||||
|
expect(await screen.findByText(/^RSA 4096 · /)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("warns while the commit author is unset and saves it", async () => {
|
it("warns while the commit author is unset and saves it", async () => {
|
||||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ author: { name: null, email: null } }));
|
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ author: { name: null, email: null } }));
|
||||||
vi.mocked(api.gitIdentity.saveAuthor).mockResolvedValue(view());
|
vi.mocked(api.gitIdentity.saveAuthor).mockResolvedValue(view());
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
|
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Copy, Eye, EyeOff, Plus, Sparkles, X } from "lucide-react";
|
import { Copy, Eye, EyeOff, Plus, Sparkles, X } from "lucide-react";
|
||||||
|
import { confirmDialog } from "../../lib/confirm";
|
||||||
import { api, type AuthUser, type ServerUserUsage, type UserPatch } from "../../lib/api";
|
import { api, type AuthUser, type ServerUserUsage, type UserPatch } from "../../lib/api";
|
||||||
import type { Lane } from "../../lib/types";
|
import type { Lane } from "../../lib/types";
|
||||||
import { fmtCost, formatDateTime } from "../../lib/format";
|
import { fmtCost, formatDateTime } from "../../lib/format";
|
||||||
@@ -131,6 +132,38 @@ export function UserEditDialog({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Delete the user for good — asked first, with what goes, in red. */
|
||||||
|
const remove = async () => {
|
||||||
|
if (self || busy) return;
|
||||||
|
if (!current) {
|
||||||
|
setError(t("delete.needsPassword"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const name = user.displayName || user.username;
|
||||||
|
const ok = await confirmDialog({
|
||||||
|
title: t("delete.title", { name }),
|
||||||
|
message: t("delete.body", { name, count: owned?.length ?? 0, user: user.username }),
|
||||||
|
confirmLabel: t("delete.confirm"),
|
||||||
|
destructive: true,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.users.remove(user.id, current);
|
||||||
|
onDone(t("done.deleted", { name }));
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as Error & { code?: string };
|
||||||
|
setError(
|
||||||
|
err.code && t(`errors.${err.code}`, { defaultValue: "" })
|
||||||
|
? t(`errors.${err.code}`)
|
||||||
|
: err.message
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||||
if (isSubmitEnter(e)) {
|
if (isSubmitEnter(e)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -459,8 +492,21 @@ export function UserEditDialog({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{!self && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void remove()}
|
||||||
|
disabled={busy}
|
||||||
|
data-testid="user-delete"
|
||||||
|
className="mr-auto rounded-md border border-status-danger/50 px-3 py-1.5 text-sm text-status-danger hover:bg-status-danger/10 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t("delete.button")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{!changed && (
|
{!changed && (
|
||||||
<span className="mr-auto text-xs text-fg-muted">{t("edit.noChanges")}</span>
|
<span className={`text-xs text-fg-muted ${self ? "mr-auto" : ""}`}>
|
||||||
|
{t("edit.noChanges")}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
<button type="button" onClick={onClose} className="btn-ghost">
|
<button type="button" onClick={onClose} className="btn-ghost">
|
||||||
{t("common:cancel")}
|
{t("common:cancel")}
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ import { render, screen, waitFor } from "@testing-library/react";
|
|||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import type { ServerUserUsage } from "../../../lib/api";
|
import type { ServerUserUsage } from "../../../lib/api";
|
||||||
|
|
||||||
const users = vi.hoisted(() => ({ get: vi.fn(), update: vi.fn() }));
|
const users = vi.hoisted(() => ({ get: vi.fn(), update: vi.fn(), remove: vi.fn() }));
|
||||||
|
const confirmDialog = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("../../../lib/confirm", () => ({ confirmDialog }));
|
||||||
const lanes = vi.hoisted(() => ({ list: vi.fn() }));
|
const lanes = vi.hoisted(() => ({ list: vi.fn() }));
|
||||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||||
const actual = await importOriginal<Record<string, unknown>>();
|
const actual = await importOriginal<Record<string, unknown>>();
|
||||||
@@ -122,6 +124,45 @@ describe("UserEditDialog", () => {
|
|||||||
expect(save().hasAttribute("disabled")).toBe(false);
|
expect(save().hasAttribute("disabled")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("deletes a user after the admin's password and a red confirm that says what goes", async () => {
|
||||||
|
users.remove.mockReset().mockResolvedValue({ user: mia });
|
||||||
|
confirmDialog.mockReset().mockResolvedValue(true);
|
||||||
|
const ue = userEvent.setup();
|
||||||
|
const { onDone } = open();
|
||||||
|
await screen.findByText("api");
|
||||||
|
// No password yet: asked for it, nothing sent.
|
||||||
|
await ue.click(screen.getByRole("button", { name: "Delete user" }));
|
||||||
|
expect(await screen.findByRole("alert")).toHaveTextContent("Enter your own password");
|
||||||
|
expect(users.remove).not.toHaveBeenCalled();
|
||||||
|
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||||
|
await ue.click(screen.getByRole("button", { name: "Delete user" }));
|
||||||
|
expect(confirmDialog).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
destructive: true,
|
||||||
|
message: expect.stringContaining("/srv/agenthub/users/mia"),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(users.remove).toHaveBeenCalledWith(2, "boss-pass"));
|
||||||
|
expect(onDone).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends nothing when the confirm is cancelled, and offers no delete for yourself", async () => {
|
||||||
|
users.remove.mockReset();
|
||||||
|
confirmDialog.mockReset().mockResolvedValue(false);
|
||||||
|
const ue = userEvent.setup();
|
||||||
|
open();
|
||||||
|
await screen.findByText("api");
|
||||||
|
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||||
|
await ue.click(screen.getByRole("button", { name: "Delete user" }));
|
||||||
|
expect(users.remove).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has no Delete button on your own account", async () => {
|
||||||
|
open(mia, true);
|
||||||
|
await screen.findByText("api");
|
||||||
|
expect(screen.queryByRole("button", { name: "Delete user" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("generates a 16-character password", async () => {
|
it("generates a 16-character password", async () => {
|
||||||
expect(generatePassword()).toHaveLength(16);
|
expect(generatePassword()).toHaveLength(16);
|
||||||
const ue = userEvent.setup();
|
const ue = userEvent.setup();
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ export const i18nReady = i18n
|
|||||||
"lanes",
|
"lanes",
|
||||||
"users",
|
"users",
|
||||||
"onboarding",
|
"onboarding",
|
||||||
|
"artifacts",
|
||||||
],
|
],
|
||||||
defaultNS: "common",
|
defaultNS: "common",
|
||||||
interpolation: { escapeValue: false },
|
interpolation: { escapeValue: false },
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"title": "Artifacts",
|
||||||
|
"subtitle": "What your agents produced — claude.ai pages, files, skills and plugins — kept in one place.",
|
||||||
|
"subtitleServer": "What your agents produced — claude.ai pages, files, skills and plugins. Share one and it appears in the Hub for everyone.",
|
||||||
|
"tab.mine": "My library",
|
||||||
|
"tab.hub": "Hub",
|
||||||
|
"kind.all": "All",
|
||||||
|
"kind.page": "Pages",
|
||||||
|
"kind.file": "Files",
|
||||||
|
"kind.skill": "Skills",
|
||||||
|
"kind.plugin": "Plugins",
|
||||||
|
"kindOne.page": "Page",
|
||||||
|
"kindOne.file": "File",
|
||||||
|
"kindOne.skill": "Skill",
|
||||||
|
"kindOne.plugin": "Plugin",
|
||||||
|
"search": "Search artifacts…",
|
||||||
|
"add": "Add",
|
||||||
|
"add.page": "claude.ai link",
|
||||||
|
"add.file": "File from a workspace",
|
||||||
|
"add.skill": "Skill",
|
||||||
|
"add.plugin": "Plugin",
|
||||||
|
"empty.mine": "Nothing here yet. Pages your sessions publish appear on their own; add files, skills and plugins with Add.",
|
||||||
|
"empty.hub": "Nobody has shared anything yet.",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"by": "by {{name}}",
|
||||||
|
"shared": "Shared",
|
||||||
|
"share": "Share",
|
||||||
|
"unshare": "Unshare",
|
||||||
|
"shareDone": "Shared to the Hub",
|
||||||
|
"unshareDone": "Removed from the Hub",
|
||||||
|
"open": "Open",
|
||||||
|
"preview": "Preview",
|
||||||
|
"download": "Download",
|
||||||
|
"install": "Install",
|
||||||
|
"installs": "{{count}} installs",
|
||||||
|
"installed": "Installed to {{path}}",
|
||||||
|
"installedPlugin": "Installed {{name}} — start a new session (or /reload-plugins) to use it",
|
||||||
|
"rename": "Rename",
|
||||||
|
"delete": "Delete",
|
||||||
|
"deleteTitle": "Delete “{{title}}”?",
|
||||||
|
"deleteBody": "It is removed from your library and the Hub. Anything already installed from it stays installed.",
|
||||||
|
"deleteConfirm": "Delete",
|
||||||
|
"installTitle": "Install “{{title}}”?",
|
||||||
|
"installSkillBody": "These files are written to your own ~/.claude/skills/{{name}}/. A skill can carry scripts your agent will run — only install what you trust:",
|
||||||
|
"installPluginBody": "Runs, as your account: claude plugin marketplace add {{market}} and claude plugin install {{name}} --scope user. Only install what you trust.",
|
||||||
|
"installConfirm": "Install",
|
||||||
|
"replaceTitle": "Replace your skill “{{name}}”?",
|
||||||
|
"replaceBody": "You already have a skill with that name. Yours is moved to ~/.claude/agenthub-replaced-skills/, not deleted.",
|
||||||
|
"replaceConfirm": "Replace",
|
||||||
|
"installsOff": "Installs are off",
|
||||||
|
"installsSwitch": "Allow installing from the Hub",
|
||||||
|
"installsSwitchHint": "Off: nobody can install a shared skill or plugin into their account.",
|
||||||
|
"form.url": "claude.ai link",
|
||||||
|
"form.title": "Title (optional)",
|
||||||
|
"form.description": "Description (optional)",
|
||||||
|
"form.workspace": "Workspace",
|
||||||
|
"form.pickWorkspace": "Pick a workspace…",
|
||||||
|
"form.noCandidates": "No documents written by this workspace's sessions yet.",
|
||||||
|
"form.pinned": "Pinned",
|
||||||
|
"form.pin": "Pin",
|
||||||
|
"form.missing": "gone",
|
||||||
|
"form.skillFrom": "Skill",
|
||||||
|
"form.fromUser": "mine",
|
||||||
|
"form.fromWorkspace": "workspace",
|
||||||
|
"form.noSkills": "No skills found in your ~/.claude/skills or this workspace's .claude/skills.",
|
||||||
|
"form.pluginKey": "Plugin (name@marketplace)",
|
||||||
|
"form.market": "Marketplace (owner/repo or git URL)",
|
||||||
|
"form.save": "Add",
|
||||||
|
"form.cancel": "Cancel",
|
||||||
|
"added": "Added",
|
||||||
|
"files": "{{count}} files",
|
||||||
|
"close": "Close",
|
||||||
|
"fromSession": "From a session",
|
||||||
|
"form.saveEdit": "Save"
|
||||||
|
}
|
||||||
@@ -417,6 +417,12 @@
|
|||||||
"home.addFolder": "Add folder",
|
"home.addFolder": "Add folder",
|
||||||
"home.folderPath": "Absolute folder path",
|
"home.folderPath": "Absolute folder path",
|
||||||
"home.add": "Add",
|
"home.add": "Add",
|
||||||
|
"home.cloneFolders": "Or clone repositories",
|
||||||
|
"home.cloneHint": "One URL per line — each is cloned into {{path}} with your own git login (Settings → Git) and added here.",
|
||||||
|
"home.clonePlaceholder": "https://host/owner/repo.git\ngit@host:owner/other.git",
|
||||||
|
"home.cloneRun": "Clone",
|
||||||
|
"home.cloneRetry": "Retry the failed ones",
|
||||||
|
"home.cloneDone": "Added",
|
||||||
"home.suggested": "Untracked worktrees",
|
"home.suggested": "Untracked worktrees",
|
||||||
"home.remove": "Remove",
|
"home.remove": "Remove",
|
||||||
"home.missing": "missing",
|
"home.missing": "missing",
|
||||||
@@ -606,5 +612,6 @@
|
|||||||
"fx.userHomes": "Users' homes",
|
"fx.userHomes": "Users' homes",
|
||||||
"fx.readOnlyHome": "Another user's home — view and download only",
|
"fx.readOnlyHome": "Another user's home — view and download only",
|
||||||
"fx.readOnly": "Read-only",
|
"fx.readOnly": "Read-only",
|
||||||
"fx.readOnlyHint": "This is another user's home: you can view, copy and download, not change it."
|
"fx.readOnlyHint": "This is another user's home: you can view, copy and download, not change it.",
|
||||||
|
"fx.homeEmpty": "Nothing in your home yet — its hidden files (.bashrc, .claude…) are not shown here. Your workspaces are on the left."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"activityFeed": "Activity Feed",
|
"activityFeed": "Activity Feed",
|
||||||
|
"artifacts": "Artifacts",
|
||||||
"agents": "Agents",
|
"agents": "Agents",
|
||||||
"analytics": "Analytics",
|
"analytics": "Analytics",
|
||||||
"avg": "avg",
|
"avg": "avg",
|
||||||
|
|||||||
@@ -57,8 +57,7 @@
|
|||||||
"attention": "Needs you",
|
"attention": "Needs you",
|
||||||
"attentionError": "errored",
|
"attentionError": "errored",
|
||||||
"attentionWaiting": "waiting on you",
|
"attentionWaiting": "waiting on you",
|
||||||
"attentionMore": "+{{count}} more in Sessions",
|
"attentionMore": "+{{count}} more in Sessions"
|
||||||
"chat": "Chat with {{name}}"
|
|
||||||
},
|
},
|
||||||
"bubbleDismiss": "Dismiss",
|
"bubbleDismiss": "Dismiss",
|
||||||
"open": "Open {{name}} companion",
|
"open": "Open {{name}} companion",
|
||||||
@@ -911,11 +910,14 @@
|
|||||||
"removeKeyConfirm": "Delete this SSH key? Clones that use it can no longer fetch or push.",
|
"removeKeyConfirm": "Delete this SSH key? Clones that use it can no longer fetch or push.",
|
||||||
"publicKey": "Public key",
|
"publicKey": "Public key",
|
||||||
"copy": "Copy public key",
|
"copy": "Copy public key",
|
||||||
"addKeyHint": "Add this public key to your account on the git host (GitHub: Settings → SSH and GPG keys; GitLab: Preferences → SSH Keys). The private key never leaves the server.",
|
"addKeyHint": "Add this public key to your account on the git host (GitHub: Settings → SSH and GPG keys; GitLab: Preferences → SSH Keys; AWS: IAM → Security credentials → SSH public keys — RSA only). Paste the whole line as one line. The private key never leaves the server.",
|
||||||
"noKey": "No SSH key yet. Without one, an SSH clone uses the account's default ~/.ssh keys.",
|
"noKey": "No SSH key yet. Without one, an SSH clone uses the account's default ~/.ssh keys.",
|
||||||
"httpsTitle": "HTTPS logins",
|
"httpsTitle": "HTTPS logins",
|
||||||
"noLogins": "No HTTPS login saved. A clone asks for one when a host needs it.",
|
"noLogins": "No HTTPS login saved. A clone asks for one when a host needs it.",
|
||||||
"removeLoginConfirm": "Delete the login for {{host}}? Clones from that host will ask for a login again."
|
"removeLoginConfirm": "Delete the login for {{host}}? Clones from that host will ask for a login again.",
|
||||||
|
"keyType": "Key type",
|
||||||
|
"keyEd25519": "ED25519 (GitHub, GitLab, Gitea)",
|
||||||
|
"keyRsa": "RSA 4096 (AWS CodeCommit, older servers)"
|
||||||
},
|
},
|
||||||
"gitCreds": {
|
"gitCreds": {
|
||||||
"title": "Git credentials",
|
"title": "Git credentials",
|
||||||
|
|||||||
@@ -57,7 +57,8 @@
|
|||||||
"enabled": "{{name}} enabled",
|
"enabled": "{{name}} enabled",
|
||||||
"password": "{{name}}'s password reset",
|
"password": "{{name}}'s password reset",
|
||||||
"workspace": "Workspace given to {{name}}",
|
"workspace": "Workspace given to {{name}}",
|
||||||
"updated": "{{name}} updated"
|
"updated": "{{name}} updated",
|
||||||
|
"deleted": "{{name}} was deleted."
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"EREAUTH": "Your password is wrong.",
|
"EREAUTH": "Your password is wrong.",
|
||||||
@@ -72,7 +73,8 @@
|
|||||||
"ENOTOWNED": "That workspace is not this user's any more.",
|
"ENOTOWNED": "That workspace is not this user's any more.",
|
||||||
"EOWNERFOLDER": "That workspace's folder belongs to another user's account.",
|
"EOWNERFOLDER": "That workspace's folder belongs to another user's account.",
|
||||||
"EBADROLE": "Unknown role.",
|
"EBADROLE": "Unknown role.",
|
||||||
"ELOCALMODE": "Accounts are managed in server mode only."
|
"ELOCALMODE": "Accounts are managed in server mode only.",
|
||||||
|
"ELASTADMIN": "The last admin cannot be deleted."
|
||||||
},
|
},
|
||||||
"readOnly": "Analysts can view accounts but not change them.",
|
"readOnly": "Analysts can view accounts but not change them.",
|
||||||
"auditTitle": "Audit log",
|
"auditTitle": "Audit log",
|
||||||
@@ -187,5 +189,12 @@
|
|||||||
"codex": "An OpenAI API key (sk-…), or the whole ~/.codex/auth.json after `codex login` with a ChatGPT account."
|
"codex": "An OpenAI API key (sk-…), or the whole ~/.codex/auth.json after `codex login` with a ChatGPT account."
|
||||||
},
|
},
|
||||||
"refreshFailed": "refresh failed"
|
"refreshFailed": "refresh failed"
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"button": "Delete user",
|
||||||
|
"title": "Delete {{name}}?",
|
||||||
|
"body": "This deletes {{name}} for good: their login, {{count}} workspace(s), and their Linux account with the whole home folder (/srv/agenthub/users/{{user}}) — every file and all source code in it. It cannot be undone. Session history and the audit log stay.",
|
||||||
|
"confirm": "Delete for good",
|
||||||
|
"needsPassword": "Enter your own password below first."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"title": "Artifacts",
|
||||||
|
"subtitle": "Những gì agent đã tạo ra — trang claude.ai, file, skill và plugin — gom một chỗ.",
|
||||||
|
"subtitleServer": "Những gì agent đã tạo ra — trang claude.ai, file, skill và plugin. Chia sẻ một mục là nó hiện trong Hub cho mọi người.",
|
||||||
|
"tab.mine": "Thư viện của tôi",
|
||||||
|
"tab.hub": "Hub",
|
||||||
|
"kind.all": "Tất cả",
|
||||||
|
"kind.page": "Trang",
|
||||||
|
"kind.file": "File",
|
||||||
|
"kind.skill": "Skill",
|
||||||
|
"kind.plugin": "Plugin",
|
||||||
|
"kindOne.page": "Trang",
|
||||||
|
"kindOne.file": "File",
|
||||||
|
"kindOne.skill": "Skill",
|
||||||
|
"kindOne.plugin": "Plugin",
|
||||||
|
"search": "Tìm artifact…",
|
||||||
|
"add": "Thêm",
|
||||||
|
"add.page": "Link claude.ai",
|
||||||
|
"add.file": "File từ workspace",
|
||||||
|
"add.skill": "Skill",
|
||||||
|
"add.plugin": "Plugin",
|
||||||
|
"empty.mine": "Chưa có gì. Trang mà phiên publish sẽ tự xuất hiện; thêm file, skill và plugin bằng nút Thêm.",
|
||||||
|
"empty.hub": "Chưa ai chia sẻ gì.",
|
||||||
|
"loading": "Đang tải…",
|
||||||
|
"by": "của {{name}}",
|
||||||
|
"shared": "Đã chia sẻ",
|
||||||
|
"share": "Chia sẻ",
|
||||||
|
"unshare": "Bỏ chia sẻ",
|
||||||
|
"shareDone": "Đã chia sẻ lên Hub",
|
||||||
|
"unshareDone": "Đã gỡ khỏi Hub",
|
||||||
|
"open": "Mở",
|
||||||
|
"preview": "Xem",
|
||||||
|
"download": "Tải về",
|
||||||
|
"install": "Cài",
|
||||||
|
"installs": "{{count}} lượt cài",
|
||||||
|
"installed": "Đã cài vào {{path}}",
|
||||||
|
"installedPlugin": "Đã cài {{name}} — mở phiên mới (hoặc /reload-plugins) để dùng",
|
||||||
|
"rename": "Đổi tên",
|
||||||
|
"delete": "Xoá",
|
||||||
|
"deleteTitle": "Xoá “{{title}}”?",
|
||||||
|
"deleteBody": "Mục này bị gỡ khỏi thư viện của bạn và Hub. Những bản người khác đã cài vẫn giữ nguyên.",
|
||||||
|
"deleteConfirm": "Xoá",
|
||||||
|
"installTitle": "Cài “{{title}}”?",
|
||||||
|
"installSkillBody": "Các file này được ghi vào ~/.claude/skills/{{name}}/ của chính bạn. Skill có thể kèm script mà agent sẽ chạy — chỉ cài thứ bạn tin:",
|
||||||
|
"installPluginBody": "Chạy dưới tài khoản của bạn: claude plugin marketplace add {{market}} và claude plugin install {{name}} --scope user. Chỉ cài thứ bạn tin.",
|
||||||
|
"installConfirm": "Cài",
|
||||||
|
"replaceTitle": "Thay skill “{{name}}” của bạn?",
|
||||||
|
"replaceBody": "Bạn đã có skill trùng tên. Bản cũ được chuyển vào ~/.claude/agenthub-replaced-skills/, không bị xoá.",
|
||||||
|
"replaceConfirm": "Thay",
|
||||||
|
"installsOff": "Đang tắt cài đặt",
|
||||||
|
"installsSwitch": "Cho phép cài từ Hub",
|
||||||
|
"installsSwitchHint": "Tắt: không ai cài được skill hay plugin chia sẻ vào tài khoản của mình.",
|
||||||
|
"form.url": "Link claude.ai",
|
||||||
|
"form.title": "Tiêu đề (tuỳ chọn)",
|
||||||
|
"form.description": "Mô tả (tuỳ chọn)",
|
||||||
|
"form.workspace": "Workspace",
|
||||||
|
"form.pickWorkspace": "Chọn workspace…",
|
||||||
|
"form.noCandidates": "Các phiên của workspace này chưa ghi tài liệu nào.",
|
||||||
|
"form.pinned": "Đã ghim",
|
||||||
|
"form.pin": "Ghim",
|
||||||
|
"form.missing": "đã mất",
|
||||||
|
"form.skillFrom": "Skill",
|
||||||
|
"form.fromUser": "của tôi",
|
||||||
|
"form.fromWorkspace": "workspace",
|
||||||
|
"form.noSkills": "Không thấy skill nào trong ~/.claude/skills của bạn hay .claude/skills của workspace này.",
|
||||||
|
"form.pluginKey": "Plugin (tên@marketplace)",
|
||||||
|
"form.market": "Marketplace (owner/repo hoặc git URL)",
|
||||||
|
"form.save": "Thêm",
|
||||||
|
"form.cancel": "Huỷ",
|
||||||
|
"added": "Đã thêm",
|
||||||
|
"files": "{{count}} file",
|
||||||
|
"close": "Đóng",
|
||||||
|
"fromSession": "Từ một phiên",
|
||||||
|
"form.saveEdit": "Lưu"
|
||||||
|
}
|
||||||
@@ -417,6 +417,12 @@
|
|||||||
"home.addFolder": "Thêm thư mục",
|
"home.addFolder": "Thêm thư mục",
|
||||||
"home.folderPath": "Đường dẫn tuyệt đối của thư mục",
|
"home.folderPath": "Đường dẫn tuyệt đối của thư mục",
|
||||||
"home.add": "Thêm",
|
"home.add": "Thêm",
|
||||||
|
"home.cloneFolders": "Hoặc clone repo",
|
||||||
|
"home.cloneHint": "Mỗi dòng một URL — từng repo được clone vào {{path}} bằng login git của bạn (Cài đặt → Git) rồi thêm vào đây.",
|
||||||
|
"home.clonePlaceholder": "https://host/owner/repo.git\ngit@host:owner/other.git",
|
||||||
|
"home.cloneRun": "Clone",
|
||||||
|
"home.cloneRetry": "Thử lại các repo lỗi",
|
||||||
|
"home.cloneDone": "Đã thêm",
|
||||||
"home.suggested": "Worktree chưa theo dõi",
|
"home.suggested": "Worktree chưa theo dõi",
|
||||||
"home.remove": "Gỡ",
|
"home.remove": "Gỡ",
|
||||||
"home.missing": "không còn",
|
"home.missing": "không còn",
|
||||||
@@ -606,5 +612,6 @@
|
|||||||
"fx.userHomes": "Home của các user",
|
"fx.userHomes": "Home của các user",
|
||||||
"fx.readOnlyHome": "Home của user khác — chỉ xem và tải về",
|
"fx.readOnlyHome": "Home của user khác — chỉ xem và tải về",
|
||||||
"fx.readOnly": "Chỉ đọc",
|
"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."
|
"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.",
|
||||||
|
"fx.homeEmpty": "Home của bạn chưa có thư mục nào — các file ẩn (.bashrc, .claude…) không hiện ở đây. Các workspace nằm ở danh sách bên trái."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"activityFeed": "Luồng hoạt động",
|
"activityFeed": "Luồng hoạt động",
|
||||||
|
"artifacts": "Artifacts",
|
||||||
"agents": "Agent",
|
"agents": "Agent",
|
||||||
"analytics": "Phân tích",
|
"analytics": "Phân tích",
|
||||||
"avg": "TB",
|
"avg": "TB",
|
||||||
|
|||||||
@@ -57,8 +57,7 @@
|
|||||||
"attention": "Cần bạn xem",
|
"attention": "Cần bạn xem",
|
||||||
"attentionError": "bị lỗi",
|
"attentionError": "bị lỗi",
|
||||||
"attentionWaiting": "đang chờ bạn",
|
"attentionWaiting": "đang chờ bạn",
|
||||||
"attentionMore": "+{{count}} phiên khác trong Phiên",
|
"attentionMore": "+{{count}} phiên khác trong Phiên"
|
||||||
"chat": "Trò chuyện với {{name}}"
|
|
||||||
},
|
},
|
||||||
"bubbleDismiss": "Đóng",
|
"bubbleDismiss": "Đóng",
|
||||||
"open": "Mở trợ lý {{name}}",
|
"open": "Mở trợ lý {{name}}",
|
||||||
@@ -911,11 +910,14 @@
|
|||||||
"removeKeyConfirm": "Xoá SSH key này? Các bản clone dùng nó sẽ không fetch/push được nữa.",
|
"removeKeyConfirm": "Xoá SSH key này? Các bản clone dùng nó sẽ không fetch/push được nữa.",
|
||||||
"publicKey": "Public key",
|
"publicKey": "Public key",
|
||||||
"copy": "Copy public key",
|
"copy": "Copy public key",
|
||||||
"addKeyHint": "Thêm public key này vào tài khoản trên git host (GitHub: Settings → SSH and GPG keys; GitLab: Preferences → SSH Keys). Private key không bao giờ rời khỏi server.",
|
"addKeyHint": "Thêm public key này vào tài khoản trên git host (GitHub: Settings → SSH and GPG keys; GitLab: Preferences → SSH Keys; AWS: IAM → Security credentials → SSH public keys — chỉ nhận RSA). Dán nguyên dòng, trên một dòng. Private key không bao giờ rời khỏi server.",
|
||||||
"noKey": "Chưa có SSH key. Khi chưa có, clone qua SSH dùng key mặc định trong ~/.ssh của tài khoản.",
|
"noKey": "Chưa có SSH key. Khi chưa có, clone qua SSH dùng key mặc định trong ~/.ssh của tài khoản.",
|
||||||
"httpsTitle": "Đăng nhập HTTPS",
|
"httpsTitle": "Đăng nhập HTTPS",
|
||||||
"noLogins": "Chưa lưu đăng nhập HTTPS nào. Khi clone, host nào cần sẽ hỏi.",
|
"noLogins": "Chưa lưu đăng nhập HTTPS nào. Khi clone, host nào cần sẽ hỏi.",
|
||||||
"removeLoginConfirm": "Xoá đăng nhập của {{host}}? Clone từ host đó sẽ hỏi đăng nhập lại."
|
"removeLoginConfirm": "Xoá đăng nhập của {{host}}? Clone từ host đó sẽ hỏi đăng nhập lại.",
|
||||||
|
"keyType": "Loại key",
|
||||||
|
"keyEd25519": "ED25519 (GitHub, GitLab, Gitea)",
|
||||||
|
"keyRsa": "RSA 4096 (AWS CodeCommit, server cũ)"
|
||||||
},
|
},
|
||||||
"gitCreds": {
|
"gitCreds": {
|
||||||
"title": "Git credentials",
|
"title": "Git credentials",
|
||||||
|
|||||||
@@ -57,7 +57,8 @@
|
|||||||
"enabled": "Đã mở khoá {{name}}",
|
"enabled": "Đã mở khoá {{name}}",
|
||||||
"password": "Đã đặt lại mật khẩu cho {{name}}",
|
"password": "Đã đặt lại mật khẩu cho {{name}}",
|
||||||
"workspace": "Đã giao workspace cho {{name}}",
|
"workspace": "Đã giao workspace cho {{name}}",
|
||||||
"updated": "Đã cập nhật {{name}}"
|
"updated": "Đã cập nhật {{name}}",
|
||||||
|
"deleted": "Đã xoá {{name}}."
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"EREAUTH": "Mật khẩu của bạn không đúng.",
|
"EREAUTH": "Mật khẩu của bạn không đúng.",
|
||||||
@@ -72,7 +73,8 @@
|
|||||||
"ENOTOWNED": "Workspace đó không còn thuộc người này.",
|
"ENOTOWNED": "Workspace đó không còn thuộc người này.",
|
||||||
"EOWNERFOLDER": "Thư mục của workspace đó thuộc tài khoản của người dùng khác.",
|
"EOWNERFOLDER": "Thư mục của workspace đó thuộc tài khoản của người dùng khác.",
|
||||||
"EBADROLE": "Vai trò không hợp lệ.",
|
"EBADROLE": "Vai trò không hợp lệ.",
|
||||||
"ELOCALMODE": "Chỉ quản lý tài khoản được ở chế độ server."
|
"ELOCALMODE": "Chỉ quản lý tài khoản được ở chế độ server.",
|
||||||
|
"ELASTADMIN": "Không thể xoá admin cuối cùng."
|
||||||
},
|
},
|
||||||
"readOnly": "Analyst chỉ xem được tài khoản, không thay đổi được.",
|
"readOnly": "Analyst chỉ xem được tài khoản, không thay đổi được.",
|
||||||
"auditTitle": "Nhật ký audit",
|
"auditTitle": "Nhật ký audit",
|
||||||
@@ -187,5 +189,12 @@
|
|||||||
"codex": "OpenAI API key (sk-…), hoặc toàn bộ ~/.codex/auth.json sau khi `codex login` bằng tài khoản ChatGPT."
|
"codex": "OpenAI API key (sk-…), hoặc toàn bộ ~/.codex/auth.json sau khi `codex login` bằng tài khoản ChatGPT."
|
||||||
},
|
},
|
||||||
"refreshFailed": "làm mới lỗi"
|
"refreshFailed": "làm mới lỗi"
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"button": "Xoá user",
|
||||||
|
"title": "Xoá {{name}}?",
|
||||||
|
"body": "Thao tác này xoá vĩnh viễn {{name}}: tài khoản đăng nhập, {{count}} workspace, và tài khoản Linux cùng toàn bộ thư mục home (/srv/agenthub/users/{{user}}) — mọi file và source code trong đó. Không thể khôi phục. Lịch sử session và audit log vẫn được giữ.",
|
||||||
|
"confirm": "Xoá vĩnh viễn",
|
||||||
|
"needsPassword": "Hãy nhập mật khẩu của bạn ở ô bên dưới trước."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+130
-6
@@ -885,6 +885,16 @@ export const api = {
|
|||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify({ ...body, currentPassword }),
|
body: JSON.stringify({ ...body, currentPassword }),
|
||||||
}),
|
}),
|
||||||
|
/**
|
||||||
|
* DELETE /api/users/:id — delete the user for good: logins, their
|
||||||
|
* workspaces' records and (one Linux user per account) their Linux
|
||||||
|
* account and whole home. Never yourself or the last admin.
|
||||||
|
*/
|
||||||
|
remove: (id: number, currentPassword: string) =>
|
||||||
|
request<{ user: AuthUser }>(`/users/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
body: JSON.stringify({ currentPassword }),
|
||||||
|
}),
|
||||||
/** POST /api/users/:id/password — a new password; that user's logins end. */
|
/** POST /api/users/:id/password — a new password; that user's logins end. */
|
||||||
setPassword: (id: number, password: string, currentPassword: string) =>
|
setPassword: (id: number, password: string, currentPassword: string) =>
|
||||||
request<{ user: AuthUser }>(`/users/${id}/password`, {
|
request<{ user: AuthUser }>(`/users/${id}/password`, {
|
||||||
@@ -2545,8 +2555,7 @@ export const api = {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ paths }),
|
body: JSON.stringify({ paths }),
|
||||||
}),
|
}),
|
||||||
trash: (t: FsPlace) =>
|
trash: (t: FsPlace) => request<{ items: FsTrashItem[]; keepDays?: number }>(fsUrl(t, "/trash")),
|
||||||
request<{ items: FsTrashItem[]; keepDays?: number }>(fsUrl(t, "/trash")),
|
|
||||||
restore: (t: FsPlace, id: string) =>
|
restore: (t: FsPlace, id: string) =>
|
||||||
request<{ path: string; laneId: number | null }>(fsUrl(t, "/trash/restore"), {
|
request<{ path: string; laneId: number | null }>(fsUrl(t, "/trash/restore"), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -2613,11 +2622,14 @@ export const api = {
|
|||||||
/** The signed-in user's own git identity (`/api/me/git`, `server/routes/git-identity.js`). */
|
/** The signed-in user's own git identity (`/api/me/git`, `server/routes/git-identity.js`). */
|
||||||
gitIdentity: {
|
gitIdentity: {
|
||||||
get: () => request<GitIdentity>("/me/git"),
|
get: () => request<GitIdentity>("/me/git"),
|
||||||
/** Make the SSH key; `replace` swaps an existing one for a new pair. */
|
/**
|
||||||
generateKey: (replace = false) =>
|
* Make the SSH key; `replace` swaps an existing one for a new pair.
|
||||||
|
* `type`: ed25519 (default) or rsa (RSA 4096 — AWS CodeCommit takes only RSA).
|
||||||
|
*/
|
||||||
|
generateKey: (replace = false, type: "ed25519" | "rsa" = "ed25519") =>
|
||||||
request<GitIdentity>("/me/git/ssh-key", {
|
request<GitIdentity>("/me/git/ssh-key", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ replace }),
|
body: JSON.stringify({ replace, type }),
|
||||||
}),
|
}),
|
||||||
removeKey: () => request<GitIdentity>("/me/git/ssh-key", { method: "DELETE" }),
|
removeKey: () => request<GitIdentity>("/me/git/ssh-key", { method: "DELETE" }),
|
||||||
saveHttps: (body: { host: string; username: string; password: string }) =>
|
saveHttps: (body: { host: string; username: string; password: string }) =>
|
||||||
@@ -2646,6 +2658,58 @@ export const api = {
|
|||||||
),
|
),
|
||||||
remove: (id: number) => request<{ ok: true }>(`/snapshots/${id}`, { method: "DELETE" }),
|
remove: (id: number) => request<{ ok: true }>(`/snapshots/${id}`, { method: "DELETE" }),
|
||||||
},
|
},
|
||||||
|
/** The Artifacts hub (`/api/artifacts`, `server/lib/artifacts.js`). */
|
||||||
|
artifacts: {
|
||||||
|
list: (params: { scope?: "mine" | "hub" | "all"; kind?: ArtifactKind; q?: string } = {}) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params.scope) qs.set("scope", params.scope);
|
||||||
|
if (params.kind) qs.set("kind", params.kind);
|
||||||
|
if (params.q) qs.set("q", params.q);
|
||||||
|
const s = qs.toString();
|
||||||
|
return request<{ items: Artifact[] }>(`/artifacts${s ? `?${s}` : ""}`);
|
||||||
|
},
|
||||||
|
settings: () => request<ArtifactSettings>("/artifacts/settings"),
|
||||||
|
setInstallsEnabled: (installsEnabled: boolean) =>
|
||||||
|
request<{ installsEnabled: boolean }>("/artifacts/settings", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ installsEnabled }),
|
||||||
|
}),
|
||||||
|
/** Documents a workspace's sessions wrote, to pin. */
|
||||||
|
candidates: (laneId: number) =>
|
||||||
|
request<{ items: ArtifactCandidate[] }>(`/artifacts/candidates?laneId=${laneId}`),
|
||||||
|
/** Skill folders the caller may share (their own, and the workspace's). */
|
||||||
|
skills: (laneId?: number) =>
|
||||||
|
request<{ items: { name: string; from: "user" | "workspace" }[] }>(
|
||||||
|
`/artifacts/skills${laneId ? `?laneId=${laneId}` : ""}`
|
||||||
|
),
|
||||||
|
addPage: (body: { url: string; title?: string; description?: string }) =>
|
||||||
|
request<{ item: Artifact }>("/artifacts/page", { method: "POST", body: JSON.stringify(body) }),
|
||||||
|
pinFile: (body: { laneId: number; path: string; title?: string; sessionId?: string }) =>
|
||||||
|
request<{ item: Artifact }>("/artifacts/file", { method: "POST", body: JSON.stringify(body) }),
|
||||||
|
addSkill: (body: { name: string; laneId?: number; title?: string; description?: string }) =>
|
||||||
|
request<{ item: Artifact }>("/artifacts/skill", { method: "POST", body: JSON.stringify(body) }),
|
||||||
|
addPlugin: (body: { key: string; market: string; title?: string; description?: string }) =>
|
||||||
|
request<{ item: Artifact }>("/artifacts/plugin", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
update: (id: number, body: { title?: string; description?: string; shared?: boolean }) =>
|
||||||
|
request<{ item: Artifact }>(`/artifacts/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||||
|
remove: (id: number) => request<{ ok: true }>(`/artifacts/${id}`, { method: "DELETE" }),
|
||||||
|
install: (id: number, replace = false) =>
|
||||||
|
request<{ installed: string; replaced?: boolean }>(`/artifacts/${id}/install`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ replace }),
|
||||||
|
}),
|
||||||
|
/** One of the snapshot's own files, served sandboxed. */
|
||||||
|
rawUrl: (id: number, path?: string, download = false) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (path) qs.set("path", path);
|
||||||
|
if (download) qs.set("download", "1");
|
||||||
|
const s = qs.toString();
|
||||||
|
return `${BASE}/artifacts/${id}/raw${s ? `?${s}` : ""}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
/** The mascot's custom pets (`/api/pets`, `server/lib/pets.js`). */
|
/** The mascot's custom pets (`/api/pets`, `server/lib/pets.js`). */
|
||||||
pets: {
|
pets: {
|
||||||
list: () => request<PetList>("/pets"),
|
list: () => request<PetList>("/pets"),
|
||||||
@@ -3640,6 +3704,21 @@ export const api = {
|
|||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify({ instructions }),
|
body: JSON.stringify({ instructions }),
|
||||||
}),
|
}),
|
||||||
|
/** POST /api/lanes/:id/sources/clone — clone a related repo BESIDE the
|
||||||
|
* workspace folder and add it as an extra folder (409 ECREDENTIALS /
|
||||||
|
* ESSHAUTH like {@link lanes.clone}). */
|
||||||
|
cloneFolder: (
|
||||||
|
id: number,
|
||||||
|
body: {
|
||||||
|
url: string;
|
||||||
|
name?: string;
|
||||||
|
credentials?: { username: string; password: string; remember?: boolean };
|
||||||
|
}
|
||||||
|
) =>
|
||||||
|
request<LaneSourcesView & { path: string }>(`/lanes/${id}/sources/clone`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
/** POST /api/lanes/:id/sources/folder — an extra folder the agent may use. */
|
/** POST /api/lanes/:id/sources/folder — an extra folder the agent may use. */
|
||||||
addFolder: (id: number, path: string) =>
|
addFolder: (id: number, path: string) =>
|
||||||
request<LaneSourcesView>(`/lanes/${id}/sources/folder`, {
|
request<LaneSourcesView>(`/lanes/${id}/sources/folder`, {
|
||||||
@@ -3820,6 +3899,51 @@ function requestCodexBackupsHelper(params?: {
|
|||||||
export type CcArtifactType =
|
export type CcArtifactType =
|
||||||
"skills" | "agents" | "commands" | "output-styles" | "memory" | "auto-memory";
|
"skills" | "agents" | "commands" | "output-styles" | "memory" | "auto-memory";
|
||||||
|
|
||||||
|
export type ArtifactKind = "page" | "file" | "skill" | "plugin";
|
||||||
|
|
||||||
|
/** One row of the Artifacts hub (`server/lib/artifacts.js` `view`). */
|
||||||
|
export interface Artifact {
|
||||||
|
id: number;
|
||||||
|
kind: ArtifactKind;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
ownerId: number | null;
|
||||||
|
owner: string | null;
|
||||||
|
mine: boolean;
|
||||||
|
canChange: boolean;
|
||||||
|
laneId: number | null;
|
||||||
|
sessionId: string | null;
|
||||||
|
/** page: the file it was published from; file: its workspace path;
|
||||||
|
* skill: its folder name; plugin: `name@marketplace`. */
|
||||||
|
source: string | null;
|
||||||
|
/** plugin: the marketplace source (owner/repo or a git URL). */
|
||||||
|
market: string | null;
|
||||||
|
/** page: the claude.ai link. */
|
||||||
|
url: string | null;
|
||||||
|
files: { path: string; size: number }[];
|
||||||
|
size: number | null;
|
||||||
|
shared: boolean;
|
||||||
|
sharedAt: string | null;
|
||||||
|
installs: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArtifactCandidate {
|
||||||
|
path: string;
|
||||||
|
sessionId: string;
|
||||||
|
at: string;
|
||||||
|
exists: boolean;
|
||||||
|
pinned: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArtifactSettings {
|
||||||
|
serverMode: boolean;
|
||||||
|
canShare: boolean;
|
||||||
|
installsEnabled: boolean;
|
||||||
|
canManage: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/** Body for PUT /api/cc-config/file - create or overwrite one artifact. */
|
/** Body for PUT /api/cc-config/file - create or overwrite one artifact. */
|
||||||
export interface CcWriteArgs {
|
export interface CcWriteArgs {
|
||||||
// "auto-memory" targets a per-project memory file and requires `project`.
|
// "auto-memory" targets a per-project memory file and requires `project`.
|
||||||
@@ -3950,7 +4074,7 @@ export interface GitIdentity {
|
|||||||
account: "member" | "host";
|
account: "member" | "host";
|
||||||
/** False for the host's identity seen by a non-admin. */
|
/** False for the host's identity seen by a non-admin. */
|
||||||
writable: boolean;
|
writable: boolean;
|
||||||
ssh: { publicKey: string; fingerprint: string } | null;
|
ssh: { type?: "ed25519" | "rsa"; publicKey: string; fingerprint: string } | null;
|
||||||
/** The account's commit author (`git config --global`); null fields = unset. */
|
/** The account's commit author (`git config --global`); null fields = unset. */
|
||||||
author: { name: string | null; email: string | null };
|
author: { name: string | null; email: string | null };
|
||||||
https: {
|
https: {
|
||||||
|
|||||||
@@ -64,6 +64,43 @@ export function droppedAttachments(dt: DataTransfer | null | undefined): Attachm
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A drop that carries no file and none of ours, only text — a file manager
|
||||||
|
* or an editor (VS Code) that hands over `file://` URIs, a selection, a
|
||||||
|
* link: the local `file://` URIs as `@path` attachments, else the text to
|
||||||
|
* insert, as a terminal pastes it. */
|
||||||
|
export function droppedText(
|
||||||
|
dt: DataTransfer | null | undefined
|
||||||
|
): { attachments: Attachment[] } | { text: string } | null {
|
||||||
|
try {
|
||||||
|
const paths = (dt?.getData("text/uri-list") ?? "")
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.filter((l) => l && !l.startsWith("#"))
|
||||||
|
.map((l) => {
|
||||||
|
try {
|
||||||
|
const u = new URL(l.trim());
|
||||||
|
return u.protocol === "file:" && (!u.host || u.host === "localhost")
|
||||||
|
? decodeURIComponent(u.pathname)
|
||||||
|
: null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (paths.length && paths.every(Boolean)) {
|
||||||
|
return {
|
||||||
|
attachments: (paths as string[]).map((p) => ({
|
||||||
|
// A space would end the mention: Claude Code reads `@"…"` whole.
|
||||||
|
path: /\s/.test(p) ? `@"${p}"` : `@${p}`,
|
||||||
|
name: p.replace(/\/$/, "").split("/").pop() || p,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const text = dt?.getData("text/plain") ?? "";
|
||||||
|
return text.trim() ? { text } : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const SKILL_MIME = "application/x-agenthub-skill";
|
export const SKILL_MIME = "application/x-agenthub-skill";
|
||||||
// A skill's name as Claude Code types it: `name` or `plugin:name`.
|
// A skill's name as Claude Code types it: `name` or `plugin:name`.
|
||||||
const SKILL_RE = /^[\w.-]+(?::[\w.-]+)?$/;
|
const SKILL_RE = /^[\w.-]+(?::[\w.-]+)?$/;
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* @file resolvePath.ts — resolves a path a chat reply names against the pane's
|
||||||
|
* folder: an absolute one (POSIX, `~`-less, or `C:\…`) is kept, a relative one
|
||||||
|
* (`server/lib/x.js`, `./a`, `../b`) is joined to `cwd` with `.`/`..` folded,
|
||||||
|
* so the preview and the Changes panel see the same absolute path the agent meant.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** `path` as an absolute path; relative ones resolve against `cwd`. */
|
||||||
|
export function resolveIn(cwd: string, path: string): string {
|
||||||
|
if (/^(?:\/|[A-Za-z]:[\\/]|\\\\)/.test(path) || !cwd) return path;
|
||||||
|
const parts = cwd.replace(/[\\/]+$/, "").split(/[\\/]/);
|
||||||
|
for (const seg of path.split(/[\\/]/)) {
|
||||||
|
if (seg === "" || seg === ".") continue;
|
||||||
|
if (seg === "..") {
|
||||||
|
if (parts.length > 1) parts.pop();
|
||||||
|
} else parts.push(seg);
|
||||||
|
}
|
||||||
|
return parts.join("/") || "/";
|
||||||
|
}
|
||||||
@@ -34,6 +34,8 @@ export const SETTINGS_INDEX: {
|
|||||||
to?: string;
|
to?: string;
|
||||||
/** Server mode: a control only an admin sees, kept out of anyone else's search. */
|
/** Server mode: a control only an admin sees, kept out of anyone else's search. */
|
||||||
adminOnly?: boolean;
|
adminOnly?: boolean;
|
||||||
|
/** Hidden in local mode (AgentHub's own git logins), so kept out of its search. */
|
||||||
|
serverOnly?: boolean;
|
||||||
}[] = [
|
}[] = [
|
||||||
{
|
{
|
||||||
section: "account",
|
section: "account",
|
||||||
@@ -330,10 +332,17 @@ export const SETTINGS_INDEX: {
|
|||||||
{ section: "agents/*", titleKey: "toolTab.defaultModel", keywords: ["model", "config"] },
|
{ section: "agents/*", titleKey: "toolTab.defaultModel", keywords: ["model", "config"] },
|
||||||
{ section: "ai-proxy", titleKey: "aiProxy.title", keywords: ["router", "endpoint", "key"] },
|
{ section: "ai-proxy", titleKey: "aiProxy.title", keywords: ["router", "endpoint", "key"] },
|
||||||
{ section: "pricing", titleKey: "pricing.title", keywords: ["cost", "price", "tokens"] },
|
{ section: "pricing", titleKey: "pricing.title", keywords: ["cost", "price", "tokens"] },
|
||||||
{ section: "git", titleKey: "gitCreds.title", adminOnly: true, keywords: ["login", "token"] },
|
{
|
||||||
|
section: "git",
|
||||||
|
titleKey: "gitCreds.title",
|
||||||
|
adminOnly: true,
|
||||||
|
serverOnly: true,
|
||||||
|
keywords: ["login", "token"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
section: "git",
|
section: "git",
|
||||||
titleKey: "gitIdentity.title",
|
titleKey: "gitIdentity.title",
|
||||||
|
serverOnly: true,
|
||||||
keywords: ["ssh", "key", "clone", "https"],
|
keywords: ["ssh", "key", "clone", "https"],
|
||||||
to: "#git-identity",
|
to: "#git-identity",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1925,7 +1925,9 @@ export interface WSMessage {
|
|||||||
/** A lane's profile hook exited, with its status code. */
|
/** A lane's profile hook exited, with its status code. */
|
||||||
| "lane_hook_result"
|
| "lane_hook_result"
|
||||||
/** A pane ended; refetch GET /api/run/interrupted (no payload). */
|
/** A pane ended; refetch GET /api/run/interrupted (no payload). */
|
||||||
| "runs_interrupted";
|
| "runs_interrupted"
|
||||||
|
/** An artifact was added, changed, shared or deleted; refetch the list. */
|
||||||
|
| "artifact_changed";
|
||||||
/** The message body, whose concrete shape is selected by `type` above. */
|
/** The message body, whose concrete shape is selected by `type` above. */
|
||||||
data:
|
data:
|
||||||
| Session
|
| Session
|
||||||
|
|||||||
@@ -0,0 +1,846 @@
|
|||||||
|
/**
|
||||||
|
* @file Artifacts.tsx
|
||||||
|
* @description `/artifacts` — the Artifacts hub (`/api/artifacts`,
|
||||||
|
* server/lib/artifacts.js): claude.ai pages sessions published, pinned
|
||||||
|
* workspace files, skills and plugins. **My library** lists the caller's
|
||||||
|
* own; in server mode the **Hub** tab lists what every user shared, and a
|
||||||
|
* shared skill or plugin is installed into the viewer's own account from
|
||||||
|
* there (after a confirmation that lists what will be written or run). An
|
||||||
|
* admin's switch turns Hub installs off for everyone. Previews of a file are
|
||||||
|
* the server's sandboxed `raw` route in a sandboxed iframe, so nothing a
|
||||||
|
* shared HTML file carries runs on the dashboard's origin.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Download,
|
||||||
|
ExternalLink,
|
||||||
|
Eye,
|
||||||
|
FileText,
|
||||||
|
Globe,
|
||||||
|
Package,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
Share2,
|
||||||
|
Sparkles,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
type Artifact,
|
||||||
|
type ArtifactCandidate,
|
||||||
|
type ArtifactKind,
|
||||||
|
type ArtifactSettings,
|
||||||
|
} from "../lib/api";
|
||||||
|
import type { Lane } from "../lib/types";
|
||||||
|
import { eventBus } from "../lib/eventBus";
|
||||||
|
import { toast } from "../lib/toast";
|
||||||
|
import { confirmDialog } from "../lib/confirm";
|
||||||
|
import { useDialog } from "../hooks/useDialog";
|
||||||
|
import { Switch } from "../components/Switch";
|
||||||
|
|
||||||
|
const KIND_ICON = { page: Globe, file: FileText, skill: Sparkles, plugin: Package } as const;
|
||||||
|
const KINDS: ArtifactKind[] = ["page", "file", "skill", "plugin"];
|
||||||
|
|
||||||
|
function fmtBytes(n: number | null): string {
|
||||||
|
if (!n) return "";
|
||||||
|
if (n < 1024) return `${n} B`;
|
||||||
|
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
|
||||||
|
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IMAGE = /\.(png|jpe?g|gif|webp|svg)$/i;
|
||||||
|
|
||||||
|
export function Artifacts() {
|
||||||
|
const { t } = useTranslation("artifacts");
|
||||||
|
const [settings, setSettings] = useState<ArtifactSettings | null>(null);
|
||||||
|
const [tab, setTab] = useState<"mine" | "hub">("mine");
|
||||||
|
const [kind, setKind] = useState<ArtifactKind | "">("");
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [items, setItems] = useState<Artifact[] | null>(null);
|
||||||
|
const [adding, setAdding] = useState<ArtifactKind | null>(null);
|
||||||
|
const [addMenu, setAddMenu] = useState(false);
|
||||||
|
const [preview, setPreview] = useState<{ item: Artifact; path: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.artifacts
|
||||||
|
.settings()
|
||||||
|
.then(setSettings)
|
||||||
|
.catch(() => setSettings(null));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
api.artifacts
|
||||||
|
.list({ scope: tab, kind: kind || undefined, q: q.trim() || undefined })
|
||||||
|
.then((r) => setItems(r.items))
|
||||||
|
.catch((err) => {
|
||||||
|
setItems([]);
|
||||||
|
toast.error(err);
|
||||||
|
});
|
||||||
|
}, [tab, kind, q]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = window.setTimeout(load, q ? 250 : 0);
|
||||||
|
return () => window.clearTimeout(id);
|
||||||
|
}, [load, q]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
eventBus.subscribe((msg) => {
|
||||||
|
if (msg.type === "artifact_changed") load();
|
||||||
|
}),
|
||||||
|
[load]
|
||||||
|
);
|
||||||
|
|
||||||
|
const server = !!settings?.serverMode;
|
||||||
|
|
||||||
|
const share = async (a: Artifact) => {
|
||||||
|
try {
|
||||||
|
await api.artifacts.update(a.id, { shared: !a.shared });
|
||||||
|
toast.success(t(a.shared ? "unshareDone" : "shareDone"));
|
||||||
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const [renaming, setRenaming] = useState<Artifact | null>(null);
|
||||||
|
|
||||||
|
const remove = async (a: Artifact) => {
|
||||||
|
if (
|
||||||
|
!(await confirmDialog({
|
||||||
|
title: t("deleteTitle", { title: a.title }),
|
||||||
|
message: t("deleteBody"),
|
||||||
|
confirmLabel: t("deleteConfirm"),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
try {
|
||||||
|
await api.artifacts.remove(a.id);
|
||||||
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const install = async (a: Artifact) => {
|
||||||
|
const message =
|
||||||
|
a.kind === "skill"
|
||||||
|
? `${t("installSkillBody", { name: a.source })} ${a.files.map((f) => f.path).join(", ")}`
|
||||||
|
: t("installPluginBody", { market: a.market, name: a.source });
|
||||||
|
if (
|
||||||
|
!(await confirmDialog({
|
||||||
|
title: t("installTitle", { title: a.title }),
|
||||||
|
message,
|
||||||
|
confirmLabel: t("installConfirm"),
|
||||||
|
destructive: false,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const done = (r: { installed: string }) =>
|
||||||
|
toast.success(
|
||||||
|
a.kind === "skill"
|
||||||
|
? t("installed", { path: r.installed })
|
||||||
|
: t("installedPlugin", { name: r.installed })
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
done(await api.artifacts.install(a.id));
|
||||||
|
} catch (err) {
|
||||||
|
const code = (err as { code?: string }).code;
|
||||||
|
if (code !== "EEXISTS") return toast.error(err);
|
||||||
|
if (
|
||||||
|
!(await confirmDialog({
|
||||||
|
title: t("replaceTitle", { name: a.source }),
|
||||||
|
message: t("replaceBody"),
|
||||||
|
confirmLabel: t("replaceConfirm"),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
try {
|
||||||
|
done(await api.artifacts.install(a.id, true));
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4" data-testid="artifacts-page">
|
||||||
|
<header className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
|
||||||
|
<p className="text-sm text-fg-muted">{t(server ? "subtitleServer" : "subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{server && settings?.canManage && (
|
||||||
|
<label
|
||||||
|
className="flex items-center gap-2 text-xs text-fg-secondary"
|
||||||
|
title={t("installsSwitchHint")}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
checked={settings.installsEnabled}
|
||||||
|
label={t("installsSwitch")}
|
||||||
|
onChange={async (v) => {
|
||||||
|
try {
|
||||||
|
const r = await api.artifacts.setInstallsEnabled(v);
|
||||||
|
setSettings({ ...settings, installsEnabled: r.installsEnabled });
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{t("installsSwitch")}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary flex items-center gap-1.5 text-sm"
|
||||||
|
data-testid="artifacts-add"
|
||||||
|
onClick={() => setAddMenu((v) => !v)}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
{t("add")}
|
||||||
|
</button>
|
||||||
|
{addMenu && (
|
||||||
|
<div className="absolute right-0 z-20 mt-1 w-56 rounded-lg border border-border bg-surface-1 p-1 shadow-xl">
|
||||||
|
{KINDS.map((k) => {
|
||||||
|
const Icon = KIND_ICON[k];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={k}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setAdding(k);
|
||||||
|
setAddMenu(false);
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{t(`add.${k}`)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{server && (
|
||||||
|
<div className="flex items-center rounded border border-border bg-surface-2 p-0.5">
|
||||||
|
{(["mine", "hub"] as const).map((v) => (
|
||||||
|
<button
|
||||||
|
key={v}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={tab === v}
|
||||||
|
onClick={() => setTab(v)}
|
||||||
|
className={`rounded px-3 py-1 text-xs font-medium ${
|
||||||
|
tab === v
|
||||||
|
? "bg-accent-solid text-white"
|
||||||
|
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(`tab.${v}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center rounded border border-border bg-surface-2 p-0.5">
|
||||||
|
{(["", ...KINDS] as const).map((k) => (
|
||||||
|
<button
|
||||||
|
key={k || "all"}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={kind === k}
|
||||||
|
onClick={() => setKind(k)}
|
||||||
|
className={`rounded px-2.5 py-1 text-xs ${
|
||||||
|
kind === k
|
||||||
|
? "bg-surface-4 text-fg-primary"
|
||||||
|
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(k ? `kind.${k}` : "kind.all")}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
placeholder={t("search")}
|
||||||
|
aria-label={t("search")}
|
||||||
|
className="ml-auto w-56 rounded border border-border bg-surface-1 px-2 py-1 text-sm text-fg-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items === null ? (
|
||||||
|
<p className="text-sm text-fg-muted">{t("loading")}</p>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed border-border px-4 py-10 text-center text-sm text-fg-muted">
|
||||||
|
{t(tab === "hub" ? "empty.hub" : "empty.mine")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3" data-testid="artifacts-list">
|
||||||
|
{items.map((a) => (
|
||||||
|
<ArtifactCard
|
||||||
|
key={a.id}
|
||||||
|
item={a}
|
||||||
|
server={server}
|
||||||
|
canInstall={!!settings?.installsEnabled}
|
||||||
|
onShare={() => void share(a)}
|
||||||
|
onRename={() => setRenaming(a)}
|
||||||
|
onDelete={() => void remove(a)}
|
||||||
|
onInstall={() => void install(a)}
|
||||||
|
onPreview={(path) => setPreview({ item: a, path })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{adding && (
|
||||||
|
<AddDialog
|
||||||
|
kind={adding}
|
||||||
|
onClose={() => setAdding(null)}
|
||||||
|
onAdded={() => {
|
||||||
|
toast.success(t("added"));
|
||||||
|
load();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{preview && <PreviewDialog {...preview} onClose={() => setPreview(null)} />}
|
||||||
|
{renaming && (
|
||||||
|
<RenameDialog
|
||||||
|
item={renaming}
|
||||||
|
onClose={() => setRenaming(null)}
|
||||||
|
onSaved={() => {
|
||||||
|
setRenaming(null);
|
||||||
|
load();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArtifactCard({
|
||||||
|
item: a,
|
||||||
|
server,
|
||||||
|
canInstall,
|
||||||
|
onShare,
|
||||||
|
onRename,
|
||||||
|
onDelete,
|
||||||
|
onInstall,
|
||||||
|
onPreview,
|
||||||
|
}: {
|
||||||
|
item: Artifact;
|
||||||
|
server: boolean;
|
||||||
|
canInstall: boolean;
|
||||||
|
onShare: () => void;
|
||||||
|
onRename: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onInstall: () => void;
|
||||||
|
onPreview: (path: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("artifacts");
|
||||||
|
const Icon = KIND_ICON[a.kind];
|
||||||
|
const btn =
|
||||||
|
"flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-3 hover:text-fg-primary";
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
className="flex flex-col gap-2 rounded-lg border border-border bg-surface-1 p-3"
|
||||||
|
data-testid={`artifact-${a.id}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Icon className="mt-0.5 h-4 w-4 shrink-0 text-fg-muted" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium text-fg-primary" title={a.title}>
|
||||||
|
{a.title}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-[11px] text-fg-muted" title={a.source || a.url || ""}>
|
||||||
|
{t(`kindOne.${a.kind}`)}
|
||||||
|
{a.source ? ` · ${a.source}` : ""}
|
||||||
|
{a.size ? ` · ${fmtBytes(a.size)}` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{a.shared && (
|
||||||
|
<span className="shrink-0 rounded bg-surface-3 px-1.5 py-0.5 text-[10px] text-fg-secondary">
|
||||||
|
{t("shared")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{a.description && (
|
||||||
|
<p className="line-clamp-2 text-xs text-fg-secondary">{a.description}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-[11px] text-fg-muted">
|
||||||
|
{server && a.owner && !a.mine ? `${t("by", { name: a.owner })} · ` : ""}
|
||||||
|
{new Date(a.createdAt).toLocaleString()}
|
||||||
|
{a.installs > 0 ? ` · ${t("installs", { count: a.installs })}` : ""}
|
||||||
|
</p>
|
||||||
|
<div className="mt-auto flex flex-wrap items-center gap-1 border-t border-border-light pt-2">
|
||||||
|
{a.kind === "page" && a.url && (
|
||||||
|
<a href={a.url} target="_blank" rel="noopener noreferrer" className={btn}>
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
{t("open")}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{(a.kind === "file" || a.kind === "skill") && a.files.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={btn}
|
||||||
|
onClick={() => onPreview(a.kind === "skill" ? "SKILL.md" : (a.files[0]?.path ?? ""))}
|
||||||
|
>
|
||||||
|
<Eye className="h-3 w-3" />
|
||||||
|
{t("preview")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{a.kind === "file" && a.files.length > 0 && (
|
||||||
|
<a href={api.artifacts.rawUrl(a.id, a.files[0]?.path, true)} className={btn}>
|
||||||
|
<Download className="h-3 w-3" />
|
||||||
|
{t("download")}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{(a.kind === "skill" || a.kind === "plugin") && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={btn}
|
||||||
|
disabled={!canInstall}
|
||||||
|
title={canInstall ? undefined : t("installsOff")}
|
||||||
|
onClick={onInstall}
|
||||||
|
>
|
||||||
|
<Download className="h-3 w-3" />
|
||||||
|
{t("install")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span className="ml-auto flex items-center gap-1">
|
||||||
|
{server && a.canChange && (
|
||||||
|
<button type="button" className={btn} onClick={onShare}>
|
||||||
|
<Share2 className="h-3 w-3" />
|
||||||
|
{t(a.shared ? "unshare" : "share")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{a.canChange && (
|
||||||
|
<>
|
||||||
|
<button type="button" className={btn} aria-label={t("rename")} onClick={onRename}>
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
<button type="button" className={btn} aria-label={t("delete")} onClick={onDelete}>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Modal({
|
||||||
|
title,
|
||||||
|
onClose,
|
||||||
|
children,
|
||||||
|
wide = false,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
onClose: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
wide?: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("artifacts");
|
||||||
|
const panel = useRef<HTMLDivElement>(null);
|
||||||
|
useDialog(true, panel, onClose);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4"
|
||||||
|
role="presentation"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={panel}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={title}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className={`flex max-h-[85vh] w-full ${wide ? "max-w-5xl" : "max-w-lg"} flex-col rounded-xl border border-border bg-surface-1 p-5 shadow-xl`}
|
||||||
|
>
|
||||||
|
<div className="mb-3 flex shrink-0 items-center justify-between gap-2">
|
||||||
|
<h2 className="truncate text-sm font-semibold text-fg-primary">{title}</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t("close")}
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-fg-muted hover:text-fg-primary"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RenameDialog({
|
||||||
|
item,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
item: Artifact;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("artifacts");
|
||||||
|
const [title, setTitle] = useState(item.title);
|
||||||
|
const [description, setDescription] = useState(item.description);
|
||||||
|
const field =
|
||||||
|
"w-full rounded border border-border bg-surface-1 px-2 py-1 text-sm text-fg-primary";
|
||||||
|
return (
|
||||||
|
<Modal title={t("rename")} onClose={onClose}>
|
||||||
|
<form
|
||||||
|
className="space-y-2"
|
||||||
|
onSubmit={async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await api.artifacts.update(item.id, { title, description });
|
||||||
|
onSaved();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className={field}
|
||||||
|
aria-label={t("form.title")}
|
||||||
|
value={title}
|
||||||
|
autoFocus
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
className={field}
|
||||||
|
rows={3}
|
||||||
|
aria-label={t("form.description")}
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button type="submit" className="btn-primary text-sm" disabled={!title.trim()}>
|
||||||
|
{t("form.saveEdit")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PreviewDialog({
|
||||||
|
item,
|
||||||
|
path,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
item: Artifact;
|
||||||
|
path: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [current, setCurrent] = useState(path);
|
||||||
|
const url = api.artifacts.rawUrl(item.id, current);
|
||||||
|
return (
|
||||||
|
<Modal title={item.title} onClose={onClose} wide>
|
||||||
|
<div className="flex h-[70vh] gap-3">
|
||||||
|
{item.files.length > 1 && (
|
||||||
|
<ul className="w-56 shrink-0 space-y-0.5 overflow-auto">
|
||||||
|
{item.files.map((f) => (
|
||||||
|
<li key={f.path}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrent(f.path)}
|
||||||
|
className={`w-full truncate rounded px-1.5 py-0.5 text-left font-mono text-[11px] ${
|
||||||
|
f.path === current
|
||||||
|
? "bg-surface-3 text-fg-primary"
|
||||||
|
: "text-fg-secondary hover:bg-surface-2"
|
||||||
|
}`}
|
||||||
|
title={f.path}
|
||||||
|
>
|
||||||
|
{f.path}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1 rounded border border-border bg-white">
|
||||||
|
{IMAGE.test(current) && !/\.svg$/i.test(current) ? (
|
||||||
|
<img src={url} alt={current} className="h-full w-full object-contain" />
|
||||||
|
) : (
|
||||||
|
// No allow-* tokens: a shared page renders, nothing in it runs.
|
||||||
|
<iframe
|
||||||
|
title={current}
|
||||||
|
src={url}
|
||||||
|
sandbox=""
|
||||||
|
className="h-full w-full"
|
||||||
|
data-testid="artifact-preview"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddDialog({
|
||||||
|
kind,
|
||||||
|
onClose,
|
||||||
|
onAdded,
|
||||||
|
}: {
|
||||||
|
kind: ArtifactKind;
|
||||||
|
onClose: () => void;
|
||||||
|
onAdded: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("artifacts");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [url, setUrl] = useState("");
|
||||||
|
const [key, setKey] = useState("");
|
||||||
|
const [market, setMarket] = useState("");
|
||||||
|
const [lanes, setLanes] = useState<Lane[]>([]);
|
||||||
|
const [laneId, setLaneId] = useState<number | null>(null);
|
||||||
|
const [candidates, setCandidates] = useState<ArtifactCandidate[] | null>(null);
|
||||||
|
const [skills, setSkills] = useState<{ name: string; from: "user" | "workspace" }[] | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (kind !== "file" && kind !== "skill") return;
|
||||||
|
api.lanes
|
||||||
|
.list()
|
||||||
|
.then((r) => setLanes(r.lanes))
|
||||||
|
.catch(() => setLanes([]));
|
||||||
|
}, [kind]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (kind === "file") {
|
||||||
|
setCandidates(null);
|
||||||
|
if (laneId)
|
||||||
|
api.artifacts
|
||||||
|
.candidates(laneId)
|
||||||
|
.then((r) => setCandidates(r.items))
|
||||||
|
.catch((err) => {
|
||||||
|
setCandidates([]);
|
||||||
|
toast.error(err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (kind === "skill") {
|
||||||
|
api.artifacts
|
||||||
|
.skills(laneId ?? undefined)
|
||||||
|
.then((r) => setSkills(r.items))
|
||||||
|
.catch(() => setSkills([]));
|
||||||
|
}
|
||||||
|
}, [kind, laneId]);
|
||||||
|
|
||||||
|
const run = async (fn: () => Promise<unknown>, close = true) => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
onAdded();
|
||||||
|
if (close) onClose();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const field =
|
||||||
|
"w-full rounded border border-border bg-surface-1 px-2 py-1 text-sm text-fg-primary";
|
||||||
|
const meta = (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
className={field}
|
||||||
|
placeholder={t("form.title")}
|
||||||
|
aria-label={t("form.title")}
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
className={field}
|
||||||
|
rows={2}
|
||||||
|
placeholder={t("form.description")}
|
||||||
|
aria-label={t("form.description")}
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
const workspacePicker = (
|
||||||
|
<select
|
||||||
|
className={field}
|
||||||
|
aria-label={t("form.workspace")}
|
||||||
|
value={laneId ?? ""}
|
||||||
|
onChange={(e) => setLaneId(e.target.value ? Number(e.target.value) : null)}
|
||||||
|
>
|
||||||
|
<option value="">{t("form.pickWorkspace")}</option>
|
||||||
|
{lanes.map((l) => (
|
||||||
|
<option key={l.id} value={l.id}>
|
||||||
|
{l.title || l.cwd}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={t(`add.${kind}`)} onClose={onClose}>
|
||||||
|
<div className="space-y-2" data-testid={`artifacts-add-${kind}`}>
|
||||||
|
{kind === "page" && (
|
||||||
|
<form
|
||||||
|
className="space-y-2"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void run(() => api.artifacts.addPage({ url, title, description }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className={field}
|
||||||
|
placeholder="https://claude.ai/…"
|
||||||
|
aria-label={t("form.url")}
|
||||||
|
value={url}
|
||||||
|
autoFocus
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
{meta}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button type="submit" className="btn-primary text-sm" disabled={busy || !url.trim()}>
|
||||||
|
{t("form.save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{kind === "file" && (
|
||||||
|
<>
|
||||||
|
{workspacePicker}
|
||||||
|
{laneId && candidates === null && (
|
||||||
|
<p className="text-xs text-fg-muted">{t("loading")}</p>
|
||||||
|
)}
|
||||||
|
{candidates && candidates.length === 0 && (
|
||||||
|
<p className="text-xs text-fg-muted">{t("form.noCandidates")}</p>
|
||||||
|
)}
|
||||||
|
{candidates && candidates.length > 0 && (
|
||||||
|
<ul className="max-h-80 space-y-0.5 overflow-auto">
|
||||||
|
{candidates.map((c) => (
|
||||||
|
<li key={c.path} className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="min-w-0 flex-1 truncate font-mono text-fg-secondary" title={c.path}>
|
||||||
|
{c.path}
|
||||||
|
</span>
|
||||||
|
{!c.exists ? (
|
||||||
|
<span className="text-fg-muted">{t("form.missing")}</span>
|
||||||
|
) : c.pinned ? (
|
||||||
|
<span className="text-fg-muted">{t("form.pinned")}</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-ghost text-xs"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
void run(async () => {
|
||||||
|
await api.artifacts.pinFile({
|
||||||
|
laneId: laneId!,
|
||||||
|
path: c.path,
|
||||||
|
sessionId: c.sessionId,
|
||||||
|
});
|
||||||
|
setCandidates((list) =>
|
||||||
|
(list ?? []).map((x) => (x.path === c.path ? { ...x, pinned: true } : x))
|
||||||
|
);
|
||||||
|
}, false)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("form.pin")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{kind === "skill" && (
|
||||||
|
<>
|
||||||
|
{workspacePicker}
|
||||||
|
{skills && skills.length === 0 && (
|
||||||
|
<p className="text-xs text-fg-muted">{t("form.noSkills")}</p>
|
||||||
|
)}
|
||||||
|
{skills && skills.length > 0 && (
|
||||||
|
<ul className="max-h-72 space-y-0.5 overflow-auto">
|
||||||
|
{skills.map((s) => (
|
||||||
|
<li key={`${s.from}:${s.name}`} className="flex items-center gap-2 text-xs">
|
||||||
|
<Sparkles className="h-3 w-3 shrink-0 text-fg-muted" />
|
||||||
|
<span className="min-w-0 flex-1 truncate text-fg-secondary">{s.name}</span>
|
||||||
|
<span className="text-fg-muted">
|
||||||
|
{t(s.from === "user" ? "form.fromUser" : "form.fromWorkspace")}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-ghost text-xs"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
void run(() =>
|
||||||
|
api.artifacts.addSkill({
|
||||||
|
name: s.name,
|
||||||
|
...(s.from === "workspace" && laneId ? { laneId } : {}),
|
||||||
|
title: title || undefined,
|
||||||
|
description: description || undefined,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("form.save")}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{meta}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{kind === "plugin" && (
|
||||||
|
<form
|
||||||
|
className="space-y-2"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void run(() => api.artifacts.addPlugin({ key, market, title, description }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className={field}
|
||||||
|
placeholder="my-plugin@my-marketplace"
|
||||||
|
aria-label={t("form.pluginKey")}
|
||||||
|
value={key}
|
||||||
|
autoFocus
|
||||||
|
onChange={(e) => setKey(e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className={field}
|
||||||
|
placeholder="owner/repo"
|
||||||
|
aria-label={t("form.market")}
|
||||||
|
value={market}
|
||||||
|
onChange={(e) => setMarket(e.target.value)}
|
||||||
|
/>
|
||||||
|
{meta}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn-primary text-sm"
|
||||||
|
disabled={busy || !key.trim() || !market.trim()}
|
||||||
|
>
|
||||||
|
{t("form.save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,9 +3,10 @@
|
|||||||
* @description `/files` — the file manager (`FileBrowser`) as a page of its
|
* @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
|
* 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
|
* 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
|
* link land in the same folder. With neither it opens the workspace last
|
||||||
* (their Linux home under S3), or — where they have none — the last
|
* shown (`agenthub.workspace.selected`) — reached from a workspace, Files is
|
||||||
* workspace shown (`agenthub.workspace.selected`), else the first.
|
* about THAT workspace — else the user's own HOME (their Linux home under S3),
|
||||||
|
* else the first workspace.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -31,24 +32,31 @@ export function FilesPage() {
|
|||||||
const [params, setParams] = useSearchParams();
|
const [params, setParams] = useSearchParams();
|
||||||
const askedLane = Number(params.get("lane"));
|
const askedLane = Number(params.get("lane"));
|
||||||
const askedHome = params.get("home");
|
const askedHome = params.get("home");
|
||||||
const [start, setStart] = useState<Start | null>(
|
// The URL wins on every render: a place picked in the browser (another
|
||||||
askedHome
|
// user's home) lands in the URL, and a frozen start would send the next
|
||||||
? { home: askedHome }
|
// folder opened there back to the first place.
|
||||||
: Number.isInteger(askedLane) && askedLane > 0
|
const asked: Start | null = askedHome
|
||||||
? { lane: askedLane }
|
? { home: askedHome }
|
||||||
: null
|
: Number.isInteger(askedLane) && askedLane > 0
|
||||||
);
|
? { lane: askedLane }
|
||||||
|
: null;
|
||||||
|
const [fallback, setStart] = useState<Start | null>(null);
|
||||||
|
const start = asked ?? fallback;
|
||||||
const [none, setNone] = useState(false);
|
const [none, setNone] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (start) return;
|
if (start) return;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const homes = await api.files.homes().catch(() => ({ homes: [] }));
|
// The workspace last shown first: Files reached from a workspace opens
|
||||||
if (homes.homes.some((h) => h.id === "me")) return setStart({ home: "me" });
|
// THAT workspace (from PhuNT → AC_SHOP it opened your own home, empty
|
||||||
|
// but for hidden files); the home is the fallback.
|
||||||
const { lanes } = await api.lanes.list().catch(() => ({ lanes: [] }));
|
const { lanes } = await api.lanes.list().catch(() => ({ lanes: [] }));
|
||||||
const last = lastWorkspace();
|
const last = lastWorkspace();
|
||||||
const pick = lanes.find((l) => l.id === last) ?? lanes[0];
|
const shown = lanes.find((l) => l.id === last);
|
||||||
if (pick) setStart({ lane: pick.id });
|
if (shown) return setStart({ lane: shown.id });
|
||||||
|
const homes = await api.files.homes().catch(() => ({ homes: [] }));
|
||||||
|
if (homes.homes.some((h) => h.id === "me")) return setStart({ home: "me" });
|
||||||
|
if (lanes[0]) setStart({ lane: lanes[0].id });
|
||||||
else setNone(true);
|
else setNone(true);
|
||||||
})();
|
})();
|
||||||
}, [start]);
|
}, [start]);
|
||||||
|
|||||||
@@ -242,7 +242,7 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
|||||||
};
|
};
|
||||||
}, [location.key, location.hash]);
|
}, [location.key, location.hash]);
|
||||||
|
|
||||||
const { isAdmin } = usePermissions();
|
const { isAdmin, local } = usePermissions();
|
||||||
const authState = useAuth().state;
|
const authState = useAuth().state;
|
||||||
const signedIn = authState.mode === "server" && !!authState.user;
|
const signedIn = authState.mode === "server" && !!authState.user;
|
||||||
const enabled = (id: string) => aiTools.byId(id)?.enabled !== false;
|
const enabled = (id: string) => aiTools.byId(id)?.enabled !== false;
|
||||||
@@ -394,15 +394,17 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
|||||||
...s("git"),
|
...s("git"),
|
||||||
render: () => (
|
render: () => (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Git logins are the host's own: an admin's. */}
|
{/* Git logins are the host's own: an admin's. Not in local mode,
|
||||||
{isAdmin && (
|
nor the identity below: git uses this machine's own config,
|
||||||
|
SSH keys and credential helper there. */}
|
||||||
|
{isAdmin && !local && (
|
||||||
<section id="git-credentials" className="scroll-mt-24">
|
<section id="git-credentials" className="scroll-mt-24">
|
||||||
<h2 className="mb-2 text-sm font-semibold text-fg-primary">{t("gitCreds.title")}</h2>
|
<h2 className="mb-2 text-sm font-semibold text-fg-primary">{t("gitCreds.title")}</h2>
|
||||||
<GitCredentialsConfig />
|
<GitCredentialsConfig />
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{/* The user's own SSH key / HTTPS logins (S3: their own home). */}
|
{/* The user's own SSH key / HTTPS logins (S3: their own home). */}
|
||||||
{(isAdmin || signedIn) && (
|
{!local && (isAdmin || signedIn) && (
|
||||||
<section id="git-identity" className="scroll-mt-24">
|
<section id="git-identity" className="scroll-mt-24">
|
||||||
<h2 className="mb-2 text-sm font-semibold text-fg-primary">
|
<h2 className="mb-2 text-sm font-semibold text-fg-primary">
|
||||||
{t("gitIdentity.title")}
|
{t("gitIdentity.title")}
|
||||||
@@ -434,6 +436,7 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
|||||||
...SETTINGS_INDEX.filter(
|
...SETTINGS_INDEX.filter(
|
||||||
(e) =>
|
(e) =>
|
||||||
(isAdmin || !e.adminOnly) &&
|
(isAdmin || !e.adminOnly) &&
|
||||||
|
(!local || !e.serverOnly) &&
|
||||||
(e.section === sec.id ||
|
(e.section === sec.id ||
|
||||||
(e.section === "agents/*" &&
|
(e.section === "agents/*" &&
|
||||||
sec.id.startsWith("agents/") &&
|
sec.id.startsWith("agents/") &&
|
||||||
|
|||||||
@@ -64,12 +64,14 @@ import {
|
|||||||
PanelLeftClose,
|
PanelLeftClose,
|
||||||
PanelRightClose,
|
PanelRightClose,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
|
FolderOpen,
|
||||||
Minimize2,
|
Minimize2,
|
||||||
ChartColumn,
|
ChartColumn,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
|
import { usePermissions } from "../components/AuthGate";
|
||||||
import type {
|
import type {
|
||||||
CwdSuggestion,
|
CwdSuggestion,
|
||||||
LaneSource,
|
LaneSource,
|
||||||
@@ -130,6 +132,7 @@ import PipelineMap from "../components/lanes/PipelineMap";
|
|||||||
import LaneCard, { since } from "../components/lanes/LaneCard";
|
import LaneCard, { since } from "../components/lanes/LaneCard";
|
||||||
import { AddLaneModal } from "../components/lanes/AddLaneModal";
|
import { AddLaneModal } from "../components/lanes/AddLaneModal";
|
||||||
import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
|
import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
|
||||||
|
import { FileExplorer } from "../components/lanes/FileExplorer";
|
||||||
import { WorkspaceContext } from "../components/lanes/WorkspaceContext";
|
import { WorkspaceContext } from "../components/lanes/WorkspaceContext";
|
||||||
import { OPEN_IN_CHANGES_EVENT, type OpenInChanges } from "../lib/openInChanges";
|
import { OPEN_IN_CHANGES_EVENT, type OpenInChanges } from "../lib/openInChanges";
|
||||||
import { LaneGitRailCounts } from "../components/lanes/LaneGitHeader";
|
import { LaneGitRailCounts } from "../components/lanes/LaneGitHeader";
|
||||||
@@ -659,6 +662,7 @@ function ConsoleArea({
|
|||||||
|
|
||||||
export function Workspace() {
|
export function Workspace() {
|
||||||
const { t: tLanes } = useTranslation("lanes");
|
const { t: tLanes } = useTranslation("lanes");
|
||||||
|
const { local } = usePermissions();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
// Lane state: the list is the shared store the sidebar reads too; the
|
// Lane state: the list is the shared store the sidebar reads too; the
|
||||||
@@ -773,6 +777,10 @@ export function Workspace() {
|
|||||||
// Whether the Changes panel is open. A viewer preference, so it lives in
|
// Whether the Changes panel is open. A viewer preference, so it lives in
|
||||||
// localStorage and never reaches the server — same rule as the lane strip's
|
// localStorage and never reaches the server — same rule as the lane strip's
|
||||||
// order (client/src/lib/laneOrderStorage.ts).
|
// order (client/src/lib/laneOrderStorage.ts).
|
||||||
|
// The File Explorer dialog, opened from the Changes panel header.
|
||||||
|
const [explorerOpen, setExplorerOpen] = useState(false);
|
||||||
|
// Bumped when the File Explorer changed files: the Files tree reads again.
|
||||||
|
const [filesChanged, setFilesChanged] = useState(0);
|
||||||
const [changesOpen, setChangesOpen] = useState(() => {
|
const [changesOpen, setChangesOpen] = useState(() => {
|
||||||
try {
|
try {
|
||||||
return window.localStorage.getItem(CHANGES_OPEN_KEY) === "1";
|
return window.localStorage.getItem(CHANGES_OPEN_KEY) === "1";
|
||||||
@@ -1511,6 +1519,35 @@ export function Workspace() {
|
|||||||
>
|
>
|
||||||
<ChartColumn className="h-4 w-4" />
|
<ChartColumn className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
{/* The File Explorer, beside the window controls — on this
|
||||||
|
workspace (the session's own worktree when it has one).
|
||||||
|
Not in local mode: the desktop's file manager is there. */}
|
||||||
|
{local ? (
|
||||||
|
<span className="ml-auto" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="panel-file-explorer"
|
||||||
|
disabled={!panelAt}
|
||||||
|
onClick={() => setExplorerOpen(true)}
|
||||||
|
aria-label={tLanes("changes.openExplorer")}
|
||||||
|
title={tLanes("changes.openExplorer")}
|
||||||
|
className="ml-auto flex h-6 w-6 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<FolderOpen className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
{panelAt && (
|
||||||
|
<FileExplorer
|
||||||
|
open={explorerOpen}
|
||||||
|
laneId={panelAt.laneId}
|
||||||
|
session={panelAt.session}
|
||||||
|
onChanged={() => setFilesChanged((n) => n + 1)}
|
||||||
|
onClose={() => setExplorerOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{/* A window's two controls: Full screen (Restore at full
|
{/* A window's two controls: Full screen (Restore at full
|
||||||
screen, to the last dragged width) and Collapse. */}
|
screen, to the last dragged width) and Collapse. */}
|
||||||
<button
|
<button
|
||||||
@@ -1519,7 +1556,7 @@ export function Workspace() {
|
|||||||
onClick={() => setChangesSize(changesFull ? lastDraggedChanges() : "full")}
|
onClick={() => setChangesSize(changesFull ? lastDraggedChanges() : "full")}
|
||||||
aria-label={changesSizeLabel}
|
aria-label={changesSizeLabel}
|
||||||
title={changesSizeLabel}
|
title={changesSizeLabel}
|
||||||
className="ml-auto flex h-6 w-6 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary"
|
className="flex h-6 w-6 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary"
|
||||||
>
|
>
|
||||||
{changesFull ? (
|
{changesFull ? (
|
||||||
<Minimize2 className="h-4 w-4" />
|
<Minimize2 className="h-4 w-4" />
|
||||||
@@ -1568,6 +1605,7 @@ export function Workspace() {
|
|||||||
wide={changesRatio(changesSize) >= 0.45}
|
wide={changesRatio(changesSize) >= 0.45}
|
||||||
onDirtyChange={dirtyFor(key)}
|
onDirtyChange={dirtyFor(key)}
|
||||||
openRequest={source ? null : changesRequest}
|
openRequest={source ? null : changesRequest}
|
||||||
|
filesChanged={source ? 0 : filesChanged}
|
||||||
// A sibling worktree forks from the lane's SOURCE repo
|
// A sibling worktree forks from the lane's SOURCE repo
|
||||||
// when it has one (this lane is itself a worktree of
|
// when it has one (this lane is itself a worktree of
|
||||||
// it); an adopted lane is its own repo.
|
// it); an adopted lane is its own repo.
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests for the `/artifacts` page: the library lists what the server
|
||||||
|
* answers, the Hub tab and Share appear only in server mode, a skill is
|
||||||
|
* installed only after a confirmation (and a taken name asks to replace),
|
||||||
|
* and a file preview is a sandboxed iframe with no `allow-*` token.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import { Artifacts } from "../Artifacts";
|
||||||
|
import { api, type Artifact } from "../../lib/api";
|
||||||
|
import { confirmDialog } from "../../lib/confirm";
|
||||||
|
|
||||||
|
vi.mock("../../lib/confirm", () => ({ confirmDialog: vi.fn() }));
|
||||||
|
vi.mock("../../lib/api", () => ({
|
||||||
|
api: {
|
||||||
|
lanes: { list: vi.fn() },
|
||||||
|
artifacts: {
|
||||||
|
settings: vi.fn(),
|
||||||
|
list: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
remove: vi.fn(),
|
||||||
|
install: vi.fn(),
|
||||||
|
setInstallsEnabled: vi.fn(),
|
||||||
|
rawUrl: (id: number, path?: string) => `/api/artifacts/${id}/raw?path=${path ?? ""}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const item = (over: Partial<Artifact>): Artifact => ({
|
||||||
|
id: 1,
|
||||||
|
kind: "file",
|
||||||
|
title: "report.html",
|
||||||
|
description: "",
|
||||||
|
ownerId: 1,
|
||||||
|
owner: "vi",
|
||||||
|
mine: true,
|
||||||
|
canChange: true,
|
||||||
|
laneId: 1,
|
||||||
|
sessionId: null,
|
||||||
|
source: "out/report.html",
|
||||||
|
market: null,
|
||||||
|
url: null,
|
||||||
|
files: [{ path: "report.html", size: 10 }],
|
||||||
|
size: 10,
|
||||||
|
shared: false,
|
||||||
|
sharedAt: null,
|
||||||
|
installs: 0,
|
||||||
|
createdAt: "2026-10-14T00:00:00Z",
|
||||||
|
updatedAt: "2026-10-14T00:00:00Z",
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const open = () =>
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<Artifacts />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(api.artifacts.settings).mockResolvedValue({
|
||||||
|
serverMode: false,
|
||||||
|
canShare: false,
|
||||||
|
installsEnabled: true,
|
||||||
|
canManage: true,
|
||||||
|
});
|
||||||
|
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
item({}),
|
||||||
|
item({ id: 2, kind: "skill", title: "deploy", source: "deploy", files: [{ path: "SKILL.md", size: 3 }] }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
vi.mocked(confirmDialog).mockReset();
|
||||||
|
vi.mocked(api.artifacts.install).mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Artifacts", () => {
|
||||||
|
it("lists the library, with no Hub or Share in local mode", async () => {
|
||||||
|
open();
|
||||||
|
expect(await screen.findByText("report.html")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("deploy")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Hub" })).toBeNull();
|
||||||
|
expect(screen.queryByRole("button", { name: /Share/ })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the Hub and Share in server mode", async () => {
|
||||||
|
vi.mocked(api.artifacts.settings).mockResolvedValue({
|
||||||
|
serverMode: true,
|
||||||
|
canShare: true,
|
||||||
|
installsEnabled: true,
|
||||||
|
canManage: false,
|
||||||
|
});
|
||||||
|
open();
|
||||||
|
expect(await screen.findByRole("button", { name: "Hub" })).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByRole("button", { name: /^Share$/ }).length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("installs a skill only after a confirmation, and asks before replacing", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
open();
|
||||||
|
await screen.findByText("deploy");
|
||||||
|
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Install" }));
|
||||||
|
expect(api.artifacts.install).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
vi.mocked(confirmDialog).mockResolvedValue(true);
|
||||||
|
vi.mocked(api.artifacts.install)
|
||||||
|
.mockRejectedValueOnce(Object.assign(new Error("taken"), { code: "EEXISTS" }))
|
||||||
|
.mockResolvedValueOnce({ installed: "/h/.claude/skills/deploy", replaced: true });
|
||||||
|
await user.click(screen.getByRole("button", { name: "Install" }));
|
||||||
|
await waitFor(() => expect(api.artifacts.install).toHaveBeenLastCalledWith(2, true));
|
||||||
|
const calls = vi.mocked(confirmDialog).mock.calls;
|
||||||
|
expect(calls[calls.length - 1]?.[0].title).toMatch(/Replace/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("previews a file in a sandboxed iframe that may run nothing", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
open();
|
||||||
|
await screen.findByText("report.html");
|
||||||
|
await user.click(screen.getAllByRole("button", { name: "Preview" })[0]!);
|
||||||
|
const frame = await screen.findByTestId("artifact-preview");
|
||||||
|
expect(frame.getAttribute("sandbox")).toBe("");
|
||||||
|
expect(frame.getAttribute("src")).toBe("/api/artifacts/1/raw?path=report.html");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests for the `/files` page's starting place: the workspace last
|
||||||
|
* shown wins (from PhuNT → AC_SHOP, Files opens AC_SHOP — not the viewer's
|
||||||
|
* own home, empty but for hidden files), the home is the fallback, and an
|
||||||
|
* explicit `?home=` / `?lane=` is obeyed — also when it changes after the
|
||||||
|
* page opened (an admin opening another user's home, then a folder in it).
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { act, render, waitFor } from "@testing-library/react";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import { FilesPage } from "../FilesPage";
|
||||||
|
import { api } from "../../lib/api";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
laneId?: number | null;
|
||||||
|
initialHome?: string | null;
|
||||||
|
onNavigate?: (place: unknown, path: string) => void;
|
||||||
|
};
|
||||||
|
const seen: Array<{ laneId?: number | null; initialHome?: string | null }> = [];
|
||||||
|
let nav: Props["onNavigate"];
|
||||||
|
vi.mock("../../components/lanes/FileExplorer", () => ({
|
||||||
|
FileBrowser: (p: Props) => {
|
||||||
|
seen.push({ laneId: p.laneId, initialHome: p.initialHome });
|
||||||
|
nav = p.onNavigate;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("../../lib/api", () => ({
|
||||||
|
isHomePlace: (p: unknown) => typeof p === "object" && p !== null && "home" in p,
|
||||||
|
api: {
|
||||||
|
lanes: { list: vi.fn() },
|
||||||
|
files: { homes: vi.fn() },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const open = (url = "/files") =>
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={[url]}>
|
||||||
|
<FilesPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
seen.length = 0;
|
||||||
|
localStorage.clear();
|
||||||
|
vi.mocked(api.lanes.list).mockResolvedValue({
|
||||||
|
lanes: [
|
||||||
|
{ id: 1, title: "AgentHub", cwd: "/a" },
|
||||||
|
{ id: 2, title: "AC_SHOP", cwd: "/srv/agenthub/users/phunt/AC_SHOP" },
|
||||||
|
],
|
||||||
|
counts: {},
|
||||||
|
} as never);
|
||||||
|
vi.mocked(api.files.homes).mockResolvedValue({
|
||||||
|
homes: [{ id: "me", label: "Vi", readOnly: false }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FilesPage", () => {
|
||||||
|
it("opens the workspace last shown, not the viewer's own home", async () => {
|
||||||
|
localStorage.setItem("agenthub.workspace.selected", "2");
|
||||||
|
open();
|
||||||
|
await waitFor(() => expect(seen[seen.length - 1]).toEqual({ laneId: 2, initialHome: null }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the home when no workspace was shown", async () => {
|
||||||
|
open();
|
||||||
|
await waitFor(() => expect(seen[seen.length - 1]).toEqual({ laneId: null, initialHome: "me" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("obeys an explicit ?home= or ?lane=", async () => {
|
||||||
|
localStorage.setItem("agenthub.workspace.selected", "2");
|
||||||
|
open("/files?home=me");
|
||||||
|
await waitFor(() => expect(seen[seen.length - 1]).toEqual({ laneId: null, initialHome: "me" }));
|
||||||
|
seen.length = 0;
|
||||||
|
open("/files?lane=1");
|
||||||
|
await waitFor(() => expect(seen[seen.length - 1]).toEqual({ laneId: 1, initialHome: null }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("follows a place picked in the browser, so a folder opened there stays there", async () => {
|
||||||
|
open("/files?home=me");
|
||||||
|
await waitFor(() => expect(nav).toBeTruthy());
|
||||||
|
act(() => nav!({ home: "phunt" }, ""));
|
||||||
|
act(() => nav!({ home: "phunt" }, "AC_SHOP"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(seen[seen.length - 1]).toEqual({ laneId: null, initialHome: "phunt" })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,6 +20,15 @@ import userEvent from "@testing-library/user-event";
|
|||||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||||
import type { AiTool, AiToolsView } from "../../lib/api";
|
import type { AiTool, AiToolsView } from "../../lib/api";
|
||||||
|
|
||||||
|
// Local mode unless a test says otherwise; still an admin either way.
|
||||||
|
const perms = vi.hoisted(() => ({ local: true }));
|
||||||
|
vi.mock("../../components/AuthGate", async (importOriginal) => {
|
||||||
|
const m = await importOriginal<typeof import("../../components/AuthGate")>();
|
||||||
|
return { ...m, usePermissions: () => ({ ...m.usePermissions(), local: perms.local }) };
|
||||||
|
});
|
||||||
|
vi.mock("../../components/settings/GitIdentitySettings", () => ({
|
||||||
|
GitIdentitySettings: () => <div data-testid="git-identity" />,
|
||||||
|
}));
|
||||||
vi.mock("../Settings", () => ({
|
vi.mock("../Settings", () => ({
|
||||||
Settings: ({ section }: { section: string }) => (
|
Settings: ({ section }: { section: string }) => (
|
||||||
<div data-testid={`settings-${section}`} data-settings-anchor={`${section}-row`} />
|
<div data-testid={`settings-${section}`} data-settings-anchor={`${section}-row`} />
|
||||||
@@ -108,6 +117,7 @@ function renderAt(at: string) {
|
|||||||
const nav = () => screen.getByRole("navigation", { name: "Settings sections" });
|
const nav = () => screen.getByRole("navigation", { name: "Settings sections" });
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
perms.local = true;
|
||||||
__resetAiTools();
|
__resetAiTools();
|
||||||
view = {
|
view = {
|
||||||
default: "claude",
|
default: "claude",
|
||||||
@@ -288,9 +298,19 @@ describe("SettingsHub", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("shows git logins and worktree suggestions on one Git page", async () => {
|
it("shows git logins and worktree suggestions on one Git page", async () => {
|
||||||
|
perms.local = false;
|
||||||
renderAt("/settings/git");
|
renderAt("/settings/git");
|
||||||
expect(await screen.findByRole("heading", { level: 1, name: "Git" })).toBeInTheDocument();
|
expect(await screen.findByRole("heading", { level: 1, name: "Git" })).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("git-credentials")).toBeInTheDocument();
|
expect(screen.getByTestId("git-credentials")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("git-identity")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("worktree-visibility")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps AgentHub's own git logins and identity off a local Git page", async () => {
|
||||||
|
renderAt("/settings/git");
|
||||||
|
expect(await screen.findByRole("heading", { level: 1, name: "Git" })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("git-credentials")).toBeNull();
|
||||||
|
expect(screen.queryByTestId("git-identity")).toBeNull();
|
||||||
expect(screen.getByTestId("worktree-visibility")).toBeInTheDocument();
|
expect(screen.getByTestId("worktree-visibility")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,12 @@ const { HOME, LANE_A, LANE_B } = vi.hoisted(() => {
|
|||||||
|
|
||||||
// The workspace's home (its own suite) fetches sources and memory; these
|
// The workspace's home (its own suite) fetches sources and memory; these
|
||||||
// tests are about the session view it hides, so it is a stub here.
|
// tests are about the session view it hides, so it is a stub here.
|
||||||
|
// Local mode unless a test says otherwise.
|
||||||
|
const perms = vi.hoisted(() => ({ local: true }));
|
||||||
|
vi.mock("../../components/AuthGate", async (importOriginal) => {
|
||||||
|
const m = await importOriginal<typeof import("../../components/AuthGate")>();
|
||||||
|
return { ...m, usePermissions: () => ({ ...m.usePermissions(), local: perms.local }) };
|
||||||
|
});
|
||||||
vi.mock("../../components/lanes/WorkspaceHome", () => ({
|
vi.mock("../../components/lanes/WorkspaceHome", () => ({
|
||||||
WorkspaceHome: () => <div data-testid="workspace-home" />,
|
WorkspaceHome: () => <div data-testid="workspace-home" />,
|
||||||
paneRunId: (id: number, slot: number) =>
|
paneRunId: (id: number, slot: number) =>
|
||||||
@@ -108,6 +114,12 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
|||||||
features: { list: r({ features: [] }), show: r({ feature: null }) },
|
features: { list: r({ features: [] }), show: r({ feature: null }) },
|
||||||
proof: { list: r({ features: [] }), imageUrl: () => "" },
|
proof: { list: r({ features: [] }), imageUrl: () => "" },
|
||||||
},
|
},
|
||||||
|
// The File Explorer opened from the panel header.
|
||||||
|
files: {
|
||||||
|
homes: r({ homes: [] }),
|
||||||
|
list: r({ path: "", parent: null, entries: [], truncated: false }),
|
||||||
|
trash: r({ items: [] }),
|
||||||
|
},
|
||||||
aiProxies: { list: r({ items: [] }) },
|
aiProxies: { list: r({ items: [] }) },
|
||||||
sessions: { list: r({ sessions: [], total: 0, limit: 50, offset: 0 }) },
|
sessions: { list: r({ sessions: [], total: 0, limit: 50, offset: 0 }) },
|
||||||
},
|
},
|
||||||
@@ -147,6 +159,7 @@ async function settle() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
perms.local = true;
|
||||||
i18n.changeLanguage("en");
|
i18n.changeLanguage("en");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -203,6 +216,22 @@ describe("Workspace — the Changes panel's width", () => {
|
|||||||
expect(card().className).toContain("w-[25%]");
|
expect(card().className).toContain("w-[25%]");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens the File Explorer from the button beside Full screen", async () => {
|
||||||
|
perms.local = false;
|
||||||
|
await openPanel();
|
||||||
|
const btn = screen.getByTestId("panel-file-explorer");
|
||||||
|
// Right before the window controls, in the same header strip.
|
||||||
|
expect(btn.nextElementSibling).toBe(screen.getByTestId("lane-changes-resize"));
|
||||||
|
fireEvent.click(btn);
|
||||||
|
expect(await screen.findByTestId("file-explorer")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has no File Explorer button in local mode", async () => {
|
||||||
|
await openPanel();
|
||||||
|
expect(screen.queryByTestId("panel-file-explorer")).toBeNull();
|
||||||
|
expect(screen.getByTestId("lane-changes-resize")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("remembers the width across a reload, and never asks the server", async () => {
|
it("remembers the width across a reload, and never asks the server", async () => {
|
||||||
await openPanel();
|
await openPanel();
|
||||||
fireEvent.click(screen.getByTestId("lane-changes-resize"));
|
fireEvent.click(screen.getByTestId("lane-changes-resize"));
|
||||||
|
|||||||
@@ -7178,7 +7178,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
|||||||
<span
|
<span
|
||||||
class="text-status-warning/90"
|
class="text-status-warning/90"
|
||||||
>
|
>
|
||||||
UI build v2.7.7 (rebuild client to match)
|
UI build v2.7.8 (rebuild client to match)
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+39
-8
@@ -87,7 +87,7 @@ A dashboard shared by several people runs in **server mode** ([SERVER-MODE.md](S
|
|||||||
|
|
||||||
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` included) with its workspaces, sessions and 7/30-day `{cost, tokens}`, and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (both `403 EADMIN` for a developer). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
|
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` included) with its workspaces, sessions and 7/30-day `{cost, tokens}`, and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (both `403 EADMIN` for a developer). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
|
||||||
|
|
||||||
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from), `POST /api/users/:id/password` (`{password}`; that user's logins end) and `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
|
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from), `POST /api/users/:id/password` (`{password}`; that user's logins end) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
|
||||||
|
|
||||||
`GET /api/accounts[?force=1]` lists the Claude account pool ([SERVER-MODE.md](SERVER-MODE.md#claude-account-pool)): `{accounts: [{name, addedAt, expiresAt, expired, livePanes, spend5h, spend7d, quota: {fiveHour, sevenDay, checkedAt, error} | null, quotaPercent, full}], codex: [{name, kind: "apikey"|"chatgpt", addedAt, livePanes, spend5h, spend7d, quota: {windows: [{label, usedPercent, resetsAt}], asOf} | null, quotaPercent, full}], blockPercent}` — `codex` is the [Codex account pool](SERVER-MODE.md#codex-account-pool). It never includes a key or a token. `force=1` re-reads every quota first. Run rows (`/api/run/history`) carry `account`, the pool account their pane ran on (`null` = the machine's own login).
|
`GET /api/accounts[?force=1]` lists the Claude account pool ([SERVER-MODE.md](SERVER-MODE.md#claude-account-pool)): `{accounts: [{name, addedAt, expiresAt, expired, livePanes, spend5h, spend7d, quota: {fiveHour, sevenDay, checkedAt, error} | null, quotaPercent, full}], codex: [{name, kind: "apikey"|"chatgpt", addedAt, livePanes, spend5h, spend7d, quota: {windows: [{label, usedPercent, resetsAt}], asOf} | null, quotaPercent, full}], blockPercent}` — `codex` is the [Codex account pool](SERVER-MODE.md#codex-account-pool). It never includes a key or a token. `force=1` re-reads every quota first. Run rows (`/api/run/history`) carry `account`, the pool account their pane ran on (`null` = the machine's own login).
|
||||||
|
|
||||||
@@ -275,7 +275,7 @@ POST /api/lanes/:id/fs/upload?path=&conflict= raw body, streamed
|
|||||||
GET /api/lanes/:id/fs/zip?p=<path>&p=… application/zip, streamed
|
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
|
The workspace **File Explorer** (server mode only — a local dashboard has no **Files** entry, `/files` redirects home, and its buttons are hidden; the routes still answer) — 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
|
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
|
`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
|
on as a link); nothing in `.git`, no secret file, never the workspace folder
|
||||||
@@ -1040,7 +1040,7 @@ cannot collide on `index.lock`.
|
|||||||
|
|
||||||
```
|
```
|
||||||
GET /api/me/git { account: "member"|"host", writable, ssh: { publicKey, fingerprint } | null, https: [{ protocol, host, username, hasToken }] }
|
GET /api/me/git { account: "member"|"host", writable, ssh: { publicKey, fingerprint } | null, https: [{ protocol, host, username, hasToken }] }
|
||||||
POST /api/me/git/ssh-key Make the SSH key — Body: { replace? } (409 EEXISTS without replace) → 201, same shape
|
POST /api/me/git/ssh-key Make the SSH key — Body: { replace?, type?: "ed25519" | "rsa" } (409 EEXISTS without replace) → 201, same shape
|
||||||
DELETE /api/me/git/ssh-key Delete it (404 ENOTFOUND)
|
DELETE /api/me/git/ssh-key Delete it (404 ENOTFOUND)
|
||||||
POST /api/me/git/https Save an https login — Body: { host, username, password }
|
POST /api/me/git/https Save an https login — Body: { host, username, password }
|
||||||
DELETE /api/me/git/https/:host Delete one (404 ENOTFOUND)
|
DELETE /api/me/git/https/:host Delete one (404 ENOTFOUND)
|
||||||
@@ -1058,8 +1058,7 @@ a prompt.
|
|||||||
The signed-in user's OWN SSH key and HTTPS logins, used by `POST
|
The signed-in user's OWN SSH key and HTTPS logins, used by `POST
|
||||||
/api/lanes/clone` (Settings → Git → **Your git identity**). With a Linux
|
/api/lanes/clone` (Settings → Git → **Your git identity**). With a Linux
|
||||||
account of their own (S3) everything lives in their home under
|
account of their own (S3) everything lives in their home under
|
||||||
`~/.config/agenthub/` — `ssh/id_ed25519` (+`.pub`, an unencrypted ed25519
|
`~/.config/agenthub/` — `ssh/id_ed25519` or `ssh/id_rsa` (+`.pub`, unencrypted, made by the server: ed25519 in OpenSSH format by default, or RSA 4096 as PKCS#1 PEM for hosts that take only RSA, such as AWS IAM/CodeCommit; one key per account, and `ssh.type` says which) and `git-credentials` (git's
|
||||||
key in OpenSSH format, made by the server) and `git-credentials` (git's
|
|
||||||
`credential-store` format) — written only AS the member (`write-as`, `0600`)
|
`credential-store` format) — written only AS the member (`write-as`, `0600`)
|
||||||
and read through `readFileSafe`; no secret crosses sudo's argv or env.
|
and read through `readFileSafe`; no secret crosses sudo's argv or env.
|
||||||
Otherwise the account is the host's: the key is
|
Otherwise the account is the host's: the key is
|
||||||
@@ -1537,6 +1536,7 @@ POST /api/locks/:name/release
|
|||||||
GET /api/lanes/:id/sources
|
GET /api/lanes/:id/sources
|
||||||
PUT /api/lanes/:id/instructions {"instructions": "…" | null}
|
PUT /api/lanes/:id/instructions {"instructions": "…" | null}
|
||||||
POST /api/lanes/:id/sources/folder {"path": "/abs/dir"}
|
POST /api/lanes/:id/sources/folder {"path": "/abs/dir"}
|
||||||
|
POST /api/lanes/:id/sources/clone {"url": "https://host/o/r.git", "name"?: "r", "credentials"?: {username, password, remember?}}
|
||||||
POST /api/lanes/:id/sources/file raw body, Content-Type: application/octet-stream, X-File-Name: <URI-encoded name>
|
POST /api/lanes/:id/sources/file raw body, Content-Type: application/octet-stream, X-File-Name: <URI-encoded name>
|
||||||
DELETE /api/lanes/:id/sources/:sourceId
|
DELETE /api/lanes/:id/sources/:sourceId
|
||||||
GET /api/lanes/:id/memory
|
GET /api/lanes/:id/memory
|
||||||
@@ -1559,6 +1559,7 @@ What a workspace's home page edits (`server/lib/lane-sources.js`, `server/routes
|
|||||||
|
|
||||||
- **Instructions** — the `instructions` key is required (`400 EBADFIELD` without it), a string or `null`; blank clears; over 16 000 characters is `413 ETOOBIG`. The lane is re-broadcast (`lane_update`). The text is stored in `lanes.instructions` but is NOT part of the lane payload (`GET /api/lanes`, `lane_update`) — read it here.
|
- **Instructions** — the `instructions` key is required (`400 EBADFIELD` without it), a string or `null`; blank clears; over 16 000 characters is `413 ETOOBIG`. The lane is re-broadcast (`lane_update`). The text is stored in `lanes.instructions` but is NOT part of the lane payload (`GET /api/lanes`, `lane_update`) — read it here.
|
||||||
- **Folder** — must be absolute, exist and be a directory (`400 EBADPATH`), must not be or contain the OS home (`400 EBADHOME`), and must not be the workspace's own folder or one it already has (`400 EDUP`). Stored by real path. Removing it only drops the row.
|
- **Folder** — must be absolute, exist and be a directory (`400 EBADPATH`), must not be or contain the OS home (`400 EBADHOME`), and must not be the workspace's own folder or one it already has (`400 EDUP`). Stored by real path. Removing it only drops the row.
|
||||||
|
- **Clone** — clones a related repo BESIDE the workspace folder (into its parent; no path is taken from the request) and adds it as a Folder. The clone is `POST /api/lanes/clone`'s own: the same URL allowlist (`400 EBADURL`), one plain `name` (default: the repo's), `409 EEXISTS` for a taken name, run as the folder's account with that account's git identity, `409 ECREDENTIALS` (with `auth: {protocol, host}`) / `ESSHAUTH` / `ENOREPO`; a tool that takes no folders is `400 EUNSUPPORTED`. Answers the view plus `path`. Removing the folder later never deletes the clone. The workspace home's **Add folder** form takes several URLs, one per line, and clones them in turn.
|
||||||
- **File** — up to 10 MB (`413 ETOOBIG`), not empty (`400 EEMPTY`). AgentHub stores it under `<data dir>/workspace-context/<lane id>/` with a name it derives from `X-File-Name` (directory parts and path characters dropped, `name (2).ext` on a clash), so a request never names a path. It is a COPY taken at upload: later edits to the original are not seen — remove and upload it again, or add its directory as a Folder (read live from disk). Removing it deletes that copy. At most 50 sources per workspace (`400 ETOOMANY`).
|
- **File** — up to 10 MB (`413 ETOOBIG`), not empty (`400 EEMPTY`). AgentHub stores it under `<data dir>/workspace-context/<lane id>/` with a name it derives from `X-File-Name` (directory parts and path characters dropped, `name (2).ext` on a clash), so a request never names a path. It is a COPY taken at upload: later edits to the original are not seen — remove and upload it again, or add its directory as a Folder (read live from disk). Removing it deletes that copy. At most 50 sources per workspace (`400 ETOOMANY`).
|
||||||
- **Memory** — read-only: the folder's `CLAUDE.md`, `.claude/CLAUDE.md`, `CLAUDE.local.md`, `AGENTS.md`, and for a Claude workspace `<CLAUDE_HOME>/projects/<encoded cwd>/memory/*.md` — `{files: [{label, path, size, mtime, truncated, content}]}`, each at most 256 KB of content.
|
- **Memory** — read-only: the folder's `CLAUDE.md`, `.claude/CLAUDE.md`, `CLAUDE.local.md`, `AGENTS.md`, and for a Claude workspace `<CLAUDE_HOME>/projects/<encoded cwd>/memory/*.md` — `{files: [{label, path, size, mtime, truncated, content}]}`, each at most 256 KB of content.
|
||||||
- Every write is same-origin only (`403 EBADORIGIN`); an unknown workspace is `404 ENOLANE`, an unknown source `404 ENOSOURCE`.
|
- Every write is same-origin only (`403 EBADORIGIN`); an unknown workspace is `404 ENOLANE`, an unknown source `404 ENOSOURCE`.
|
||||||
@@ -1780,7 +1781,7 @@ curl http://localhost:4820/api/sessions/sess_abc123/outline
|
|||||||
GET /api/sessions/:id/image?path=<absolute path>
|
GET /api/sessions/:id/image?path=<absolute path>
|
||||||
```
|
```
|
||||||
|
|
||||||
The bytes of an image the session's own `Read` tool read (a `PostToolUse` `Read` event of this session whose `tool_input.file_path` equals `path` exactly) — how a Chat pane's Files read preview shows a screenshot outside the workspace folder. Raster only (`png`, `jpg`/`jpeg`, `gif`, `webp`, else 400 `ENOTIMAGE`), at most 20 MB (`ETOOBIG`), served with a `sandbox` CSP, `nosniff` and `no-store`. 404 for an unknown session, a path the session never read, or a file that is gone; 403 `EUNSUPPORTED` for a session pushed from another machine (`push:<source>`), whose paths are not this host's to read, and in server mode without S3. With S3 a member's session (its folder under the users root) gets only a file inside that member's home that the member owns (else 404), a host session any file — a member's hook can only report sessions in its own home.
|
The bytes of an image the session's own `Read` tool read (a `PostToolUse` `Read` event of this session whose `tool_input.file_path` equals `path` exactly) — how a Chat pane's Files read preview shows a screenshot outside the workspace folder — or one the session NAMED (see `/file` below: a chart its script drew). Raster only (`png`, `jpg`/`jpeg`, `gif`, `webp`, else 400 `ENOTIMAGE`), at most 20 MB (`ETOOBIG`), served with a `sandbox` CSP, `nosniff` and `no-store`. 404 for an unknown session, a path the session never read, or a file that is gone; 403 `EUNSUPPORTED` for a session pushed from another machine (`push:<source>`), whose paths are not this host's to read, and in server mode without S3. With S3 a member's session (its folder under the users root) gets only a file inside that member's home that the member owns (else 404), a host session any file — a member's hook can only report sessions in its own home.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -o shot.png "http://localhost:4820/api/sessions/sess_abc123/image?path=/home/me/.cache/shot.png"
|
curl -o shot.png "http://localhost:4820/api/sessions/sess_abc123/image?path=/home/me/.cache/shot.png"
|
||||||
@@ -1794,7 +1795,7 @@ curl -o shot.png "http://localhost:4820/api/sessions/sess_abc123/image?path=/hom
|
|||||||
GET /api/sessions/:id/file?path=<absolute path>
|
GET /api/sessions/:id/file?path=<absolute path>
|
||||||
```
|
```
|
||||||
|
|
||||||
`{ "text": "...", "truncated": false }` for a text file the session's own `Read`, `Write`, `Edit`, `MultiEdit` or `NotebookEdit` named (a `PostToolUse` event of this session whose `tool_input.file_path` or `notebook_path` equals `path` exactly) — how a Chat pane's Outputs/Files preview shows a file outside the workspace folder (a report written to `~/Documents`). At most the first 512 KB (`truncated: true` beyond). 400 `EBADPATH` for a relative path; 403 `ESECRET` for a credential by name (`isSecretConfigPath` — `.credentials.json`, `auth.json`, AgentHub's own stores and data dir — or a `settings.local.json`), judged on the path and on what it resolves to; 415 `EBINARY` for a file with a NUL byte in its first 8 KB; 404 for an unknown session, a path the session never touched, or a non-regular or missing file. 403 `EUNSUPPORTED` and the S3 member rules are the same as for `/image` above.
|
`{ "text": "...", "truncated": false }` for a text file the session's own `Read`, `Write`, `Edit`, `MultiEdit` or `NotebookEdit` named (a `PostToolUse` event of this session whose `tool_input.file_path` or `notebook_path` equals `path` exactly) — how a Chat pane's Outputs/Files preview shows a file outside the workspace folder (a report written to `~/Documents`). A file the session only NAMED is served too (issue #76: a report its own script generated): `path` appears as a whole path — not as the prefix of a longer one — in one of its `PostToolUse` `Bash` events' `tool_input.command` or `tool_response.stdout`, or in a `Stop`/`SubagentStop` event's `last_assistant_message`, and the session's account could read it itself (`access(2)`, which ignores the S3 unit's `CAP_DAC_READ_SEARCH`; for a member's session the home/owner rule below decides). At most the first 512 KB (`truncated: true` beyond). 400 `EBADPATH` for a relative path; 403 `ESECRET` for a credential by name (`isSecretConfigPath` — `.credentials.json`, `auth.json`, AgentHub's own stores and data dir — or a `settings.local.json`), judged on the path and on what it resolves to; 415 `EBINARY` for a file with a NUL byte in its first 8 KB; 404 for an unknown session, a path the session never touched, or a non-regular or missing file. 403 `EUNSUPPORTED` and the S3 member rules are the same as for `/image` above.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl "http://localhost:4820/api/sessions/sess_abc123/file?path=/home/me/Documents/REPORT.md"
|
curl "http://localhost:4820/api/sessions/sess_abc123/file?path=/home/me/Documents/REPORT.md"
|
||||||
@@ -2818,7 +2819,7 @@ DELETE /api/run/:id Kill (SIGTERM → SIGKILL after 5 s)
|
|||||||
|
|
||||||
**PTY streaming:** Frames from the tmux pane are streamed to the client over `/ws-pty/:runId` as binary WebSocket frames (not JSON). The Workspace page's TerminalView component feeds these frames to xterm.js for live rendering. Simultaneously, `agenthub workspace shell` can attach the same session via a real local terminal, staying in sync with the browser view.
|
**PTY streaming:** Frames from the tmux pane are streamed to the client over `/ws-pty/:runId` as binary WebSocket frames (not JSON). The Workspace page's TerminalView component feeds these frames to xterm.js for live rendering. Simultaneously, `agenthub workspace shell` can attach the same session via a real local terminal, staying in sync with the browser view.
|
||||||
|
|
||||||
Every history row carries `lane_id` (the workspace whose run it belongs to) and `tool` (the CLI that was invoked: `"claude"` or `"codex"`). A run's `id` is its tmux pane name, which each Start in that pane reuses, so the run it replaces is kept as its own row with `id` = `<pane name>@<its started_at>` (status `abandoned` if it never recorded an end). A row still marked `running` is ended once its tmux session is gone — checked at server start and on the 10 s run-heal beat (`DASHBOARD_RUN_HEAL_MS`); a pane that survived a server restart stays `running`. It becomes `interrupted` when an agent session (not a Terminal tab, `session_id` known) died under the user — a reboot, a killed tmux, a crash; a Claude session the user ended (`SessionEnd` with reason `prompt_input_exit`/`logout`) and every row with no session become `abandoned`. Only Claude reports why it ended, so any other tool's vanished pane counts as interrupted. A pane the dashboard closed (Stop, close tab, Quit) is already `killed`. `GET /api/run/interrupted` lists the newest interrupted row of each session no newer run holds (`{id, session_id, lane_id, tool, cwd, model, permission_mode, effort, prompt_preview, started_at, ended_at, lane_title, lane_cwd, session_name}`), only the caller's own workspaces in server mode — the app-wide **Resume interrupted sessions?** dialog reads it; dismissing flips them to `abandoned`. The heal beat broadcasts `runs_interrupted` (no payload) whenever it ended a row, and every client refetches. `GET /api/run/history?laneId=<n>` returns only that workspace's runs. Spawned Claude processes fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, and the Dashboard's Analytics tab (Workflow sub-tab included) automatically. Codex runs do not emit these hooks; they are recorded only in run history and `dashboard_runs`.
|
Every history row carries `lane_id` (the workspace whose run it belongs to) and `tool` (the CLI that was invoked: `"claude"` or `"codex"`). A run's `id` is its tmux pane name, which each Start in that pane reuses, so the run it replaces is kept as its own row with `id` = `<pane name>@<its started_at>` (status `abandoned` if it never recorded an end). A row still marked `running` is ended once its tmux session is gone — checked at server start and on the 10 s run-heal beat (`DASHBOARD_RUN_HEAL_MS`); a pane that survived a server restart stays `running`. It becomes `interrupted` when an agent session (not a Terminal tab, `session_id` known) died under the user — a reboot, a killed tmux, a crash; a Claude session the user ended (`SessionEnd` with reason `prompt_input_exit`/`logout`) and every row with no session become `abandoned`. A pane whose tmux server has run since before the pane started ended by itself (the user quit Codex, `exit`ed, closed it), so it is `abandoned` too — for Claude only when it sent any `SessionEnd` (none at all is a crash); a pane whose server is gone or restarted, or cannot be asked, died with it and stays `interrupted`. A pane the dashboard closed (Stop, close tab, Quit) is already `killed`. `GET /api/run/interrupted` lists the newest interrupted row of each session no newer run holds (`{id, session_id, lane_id, tool, cwd, model, permission_mode, effort, prompt_preview, started_at, ended_at, lane_title, lane_cwd, session_name}`), only the caller's own workspaces in server mode — the app-wide **Resume interrupted sessions?** dialog reads it; dismissing flips them to `abandoned`. The heal beat broadcasts `runs_interrupted` (no payload) whenever it ended a row, and every client refetches. `GET /api/run/history?laneId=<n>` returns only that workspace's runs. Spawned Claude processes fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, and the Dashboard's Analytics tab (Workflow sub-tab included) automatically. Codex runs do not emit these hooks; they are recorded only in run history and `dashboard_runs`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -2939,6 +2940,36 @@ DELETE /api/snapshots/:id { ok: true }
|
|||||||
|
|
||||||
A snapshot freezes a session as it is at that moment into one immutable `session_snapshots` row (`server/routes/snapshots.js`): `payload` is `{ version: 1, captured_at, redacted: { file_paths }, session, agents, events, stats, cost }` — the session row, its agents (each with its own `cost`), every stored event oldest first (`events.data` exactly as stored), the `/api/sessions/:id/stats` body and the `/api/pricing/cost/:id` result priced at the session's start date. Its `token` is 24 random bytes in base64url (32 characters) and is the only way to read it; the page is `/snapshot/<token>`. With `redact.file_paths`, paths are replaced **before anything is stored**: every `cwd`, `transcript_path`, `agent_transcript_path`, `file_path`, `filePath`, `notebook_path` and `path` value (also inside `events.data` and `metadata` JSON) becomes `[redacted]`, and the session's cwd / the home directory inside any other string (a Bash command, a summary) become `<cwd>` / `~`. A row cannot be updated (a trigger aborts it), outlives its session, and survives **Clear Data** — it goes only through `DELETE` (Settings → **Snapshots**). Both writes are same-origin (`403 EBADORIGIN`); `400 INVALID_INPUT` without `session_id`, `404 NOT_FOUND` for an unknown session, token or id.
|
A snapshot freezes a session as it is at that moment into one immutable `session_snapshots` row (`server/routes/snapshots.js`): `payload` is `{ version: 1, captured_at, redacted: { file_paths }, session, agents, events, stats, cost }` — the session row, its agents (each with its own `cost`), every stored event oldest first (`events.data` exactly as stored), the `/api/sessions/:id/stats` body and the `/api/pricing/cost/:id` result priced at the session's start date. Its `token` is 24 random bytes in base64url (32 characters) and is the only way to read it; the page is `/snapshot/<token>`. With `redact.file_paths`, paths are replaced **before anything is stored**: every `cwd`, `transcript_path`, `agent_transcript_path`, `file_path`, `filePath`, `notebook_path` and `path` value (also inside `events.data` and `metadata` JSON) becomes `[redacted]`, and the session's cwd / the home directory inside any other string (a Bash command, a summary) become `<cwd>` / `~`. A row cannot be updated (a trigger aborts it), outlives its session, and survives **Clear Data** — it goes only through `DELETE` (Settings → **Snapshots**). Both writes are same-origin (`403 EBADORIGIN`); `400 INVALID_INPUT` without `session_id`, `404 NOT_FOUND` for an unknown session, token or id.
|
||||||
|
|
||||||
|
### Artifacts hub
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/artifacts?scope=mine|hub&kind=page|file|skill|plugin&q= {items}
|
||||||
|
GET /api/artifacts/settings {serverMode, canShare, installsEnabled, canManage}
|
||||||
|
PUT /api/artifacts/settings {installsEnabled: boolean} — admin (any caller in local mode)
|
||||||
|
GET /api/artifacts/candidates?laneId= {items: [{path, sessionId, at, exists, pinned}]}
|
||||||
|
GET /api/artifacts/skills?laneId= {items: [{name, from: "user"|"workspace"}]}
|
||||||
|
POST /api/artifacts/page {url, title?, description?}
|
||||||
|
POST /api/artifacts/file {laneId, path, title?, sessionId?}
|
||||||
|
POST /api/artifacts/skill {name, laneId?, title?, description?}
|
||||||
|
POST /api/artifacts/plugin {key: "name@marketplace", market: "owner/repo" | git URL, title?, description?}
|
||||||
|
GET /api/artifacts/:id {item}
|
||||||
|
PATCH /api/artifacts/:id {title?, description?, shared?}
|
||||||
|
DELETE /api/artifacts/:id
|
||||||
|
GET /api/artifacts/:id/raw?path=&download=1 one snapshot file (sandbox CSP, nosniff)
|
||||||
|
POST /api/artifacts/:id/install {replace?} → {installed, replaced?}
|
||||||
|
```
|
||||||
|
|
||||||
|
What agents produced, per OWNER (`server/lib/artifacts.js`, plan: `docs/plans/artifacts/README.md`):
|
||||||
|
|
||||||
|
- **page** — a claude.ai link (`https://claude.ai/…` only, `400 EBADURL`), one row per owner and link. A session's `Artifact` tool publish is captured from its PostToolUse hook (the first `https://claude.ai/…artifact/…` in `tool_response`), owned by the session's workspace owner.
|
||||||
|
- **file** — `candidates` are the documents (html, md, pdf, images, csv, office…) the workspace's sessions wrote with Write/Edit/MultiEdit/NotebookEdit, inside its folder. A pin is read under the download route's guards (`lanePath`, `resolveInLane`, `ESECRET`, ≤ 25 MB, `413 ETOOBIG`) and SNAPSHOTTED to `<data dir>/artifacts/<random>/`.
|
||||||
|
- **skill** — the caller's own `~/.claude/skills/<name>` (their member home under S3; the host's for an admin or in local mode, else `409 ENOACCOUNT`) or a workspace's `.claude/skills/<name>`. Snapshotted whole: ≤ 300 files, ≤ 5 MB, a symlink anywhere is `ESYMLINK`, a secret-looking file name (`.env`, `*.pem`, `*token*`…) `403 ESECRET`.
|
||||||
|
- **plugin** — only the spec and its marketplace source (`EBADPLUGIN`, `EBADMARKET`: `owner/repo` or a `validateRemoteUrl` URL); no files.
|
||||||
|
- **Visibility** — local mode: every row, no sharing (`400 ELOCALMODE`). Server mode: a developer sees their own rows and shared ones, admin/analyst every row; only the owner or an admin changes or deletes one (`403 EFORBIDDEN`). `shared` puts a row in every user's **Hub**.
|
||||||
|
- **raw** — only a file the snapshot recorded (`404 ENOFILE` otherwise), never a request path; served with `Content-Security-Policy: …; sandbox` and `nosniff`, unknown types as an attachment.
|
||||||
|
- **install** — a skill or plugin (`EBADKIND`), into the CALLER's own account: a skill is written to `~/.claude/skills/<name>/` (as the member through `write-as` under S3); a taken name is `409 EEXISTS` unless `replace: true`, which moves the old one to `~/.claude/agenthub-replaced-skills/<name>-<time>/`. A plugin runs `claude plugin marketplace add <market>` then `claude plugin install --scope user <key>` as the caller, never with `-y`. `403 EINSTALLSOFF` while an admin has turned installs off. Share, unshare, a shared row's delete and every install by someone else are written to `audit_log`.
|
||||||
|
- Every write is same-origin. The `artifact_changed` broadcast (`{id, ownerId, shared}`) reaches everyone for a shared row, else its owner and staff.
|
||||||
|
|
||||||
### Query Explorer
|
### Query Explorer
|
||||||
|
|
||||||
The dashboard's Sessions page reads its **Agents** tab (`/sessions?view=agents`; the old `/query` page redirects there) from `entity=agents`; sessions and events have their own tabs over `GET /api/sessions` and `GET /api/events`. The route still answers all three entities for scripts and exports.
|
The dashboard's Sessions page reads its **Agents** tab (`/sessions?view=agents`; the old `/query` page redirects there) from `entity=agents`; sessions and events have their own tabs over `GET /api/sessions` and `GET /api/events`. The route still answers all three entities for scripts and exports.
|
||||||
|
|||||||
+7
-3
File diff suppressed because one or more lines are too long
@@ -726,6 +726,21 @@ sudo agenthub server install-system
|
|||||||
|
|
||||||
Until then the File Explorer's move, copy and delete in a member's folder
|
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).
|
answer `409 EUPGRADE` (listing, upload, new folder/file and zip work).
|
||||||
|
The same re-run installs `agenthub-provision delete`, behind the Users page's
|
||||||
|
**Delete user**: until then a delete answers `502 EPROVISION` ("unknown
|
||||||
|
action: delete") and changes nothing.
|
||||||
|
|
||||||
|
### Deleting a user
|
||||||
|
|
||||||
|
Users page → Edit → **Delete user** (the admin's own password, then a red
|
||||||
|
confirm). Order: the user's logins end and their workspaces' panes stop;
|
||||||
|
under S3 `agenthub-provision delete <name>` locks `ah-<name>`, kills its
|
||||||
|
processes, `userdel`s it and removes `<USERS_ROOT>/<name>` (a real
|
||||||
|
directory exactly there — never a link — `rm -rf --one-file-system`); only
|
||||||
|
then are the rows removed (the user, their workspaces' records, prefs, push
|
||||||
|
subscriptions, personal AI Proxy profiles). Session history and the audit log
|
||||||
|
stay. Never yourself (`ESELF`) or the last admin (`ELASTADMIN`). A user made
|
||||||
|
again under the same name gets a NEW, empty Linux account and home.
|
||||||
|
|
||||||
### What a new machine needs before `install-system`
|
### What a new machine needs before `install-system`
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Artifacts hub — plan
|
||||||
|
|
||||||
|
_Drafted 2026-10-14. Status: all three phases built 2026-10-14 (the session view's own Artifacts tab is not built; the page has the per-workspace file picker)._
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
One place to keep what agents PRODUCE, and (in server mode) to share it:
|
||||||
|
|
||||||
|
| Kind | What it is | Where it comes from |
|
||||||
|
|---|---|---|
|
||||||
|
| `page` | A claude.ai Artifact a session published (`claude.ai/…/artifact/…`) | the `Artifact` tool's PostToolUse hook, or a link pasted by hand |
|
||||||
|
| `file` | A file an agent wrote (HTML, Markdown, PDF, image, CSV, Office…) | Write/Edit/NotebookEdit hook events, kept on the user's pin |
|
||||||
|
| `skill` | A Claude Code skill folder (`SKILL.md` + files) | the user's own `~/.claude/skills/<name>` or a workspace's `.claude/skills/<name>` |
|
||||||
|
| `plugin` | A plugin by marketplace source + name (no files) | the user's installed plugins (`claude plugin list`) |
|
||||||
|
|
||||||
|
- **Local mode:** one personal library, no sharing UI.
|
||||||
|
- **Server mode:** every artifact is the OWNER's and private by default. The owner can **Share** it; a shared artifact appears in the **Hub** for every signed-in user. A shared skill or plugin can be **installed** into the viewer's own account.
|
||||||
|
|
||||||
|
## Rules (binding, same spirit as CLAUDE.md)
|
||||||
|
|
||||||
|
- **What is shared is a SNAPSHOT, never the owner's live file.** Sharing a `file`/`skill` copies it (size-capped, regular files only, no symlinks followed) into `<data dir>/artifacts/<id>/`, written once like `session_snapshots`. Viewers read only that copy, so no route ever reads another user's home for them. Re-sharing makes a new snapshot.
|
||||||
|
- **Nothing shared runs on the dashboard's origin.** HTML/SVG previews are served under the `/git/raw` headers (`sandbox` CSP + `nosniff`) and shown in a sandboxed iframe; a `page` is only a link out, accepted only as `https://claude.ai/…`.
|
||||||
|
- **Installing is the viewer's explicit click, into the viewer's own account only.** For a skill, the hub writes the snapshot's files into `~/.claude/skills/<name>/` of the VIEWER (S3: as their member, through `write-as`). It never overwrites an existing skill without `conflict: replace`, and the old one goes to the trash first. For a plugin, the hub runs `claude plugin marketplace add <source>` + `claude plugin install <name>@<market> --scope user` as the viewer (the `applyPluginBaseline` path). The marketplace source passes an allowlist (`owner/repo` or a `validateRemoteUrl` URL), and no argv comes from the request beyond those two validated values.
|
||||||
|
- **Before installing, the dialog lists every file of the skill** (a skill may carry scripts that the viewer's agent will run). Every share, unshare and install is written to `audit_log`.
|
||||||
|
- **Visibility follows ownership:** a developer sees their own artifacts plus shared ones; admin/analyst see per `scope.js`, like sessions. Unshare removes an artifact from the Hub but does not uninstall copies others already installed.
|
||||||
|
|
||||||
|
## Data
|
||||||
|
|
||||||
|
New table `artifacts` (`CREATE TABLE IF NOT EXISTS` in `server/db.js`):
|
||||||
|
|
||||||
|
`id, kind, title, description, owner_id, lane_id, session_id, source (path | url | market spec), url, snapshot (dir name | NULL), size, shared (0/1), shared_at, installs, created_at, updated_at`, plus an index on `(owner_id, kind)` and on `(shared, kind)`.
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
1. **Library (local + server, private).** Table, `server/lib/artifacts.js`, `/api/artifacts` (list/get/create/patch/delete). `page` rows are captured from the `Artifact` PostToolUse hook (URL taken from `tool_response` by an `https://claude.ai/…artifact/…` match). `file` candidates are listed per workspace from Write events with a document extension, and the user pins them. New **Artifacts** page (sidebar) plus an **Artifacts** tab in the session view's right panel. Scope wiring: `PATH_RULES`, `broadcastAudience`.
|
||||||
|
2. **Hub (server mode).** Share/Unshare buttons, snapshot, `Hub` tab, sandboxed preview, download, `artifact_shared` broadcast.
|
||||||
|
3. **Skills & plugins.** "Share a skill/plugin" picker, the install flow (S3 included), conflict handling, audit.
|
||||||
|
|
||||||
|
Each phase ships with its server and client tests and an update to `docs/API.md`.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- No `Artifact` tool call has happened on this machine yet, so the exact hook payload of a publish is unverified. Phase 1 matches the URL loosely and keeps "paste a link" as the fallback.
|
||||||
|
- Should an admin be able to disable Hub installs for everyone (one switch in Settings)? Proposed: yes, on by default.
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.7.7",
|
"version": "2.7.8",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.7.7",
|
"version": "2.7.8",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.7.7",
|
"version": "2.7.8",
|
||||||
"bin": {
|
"bin": {
|
||||||
"agenthub": "bin/agenthub.js"
|
"agenthub": "bin/agenthub.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,11 +7,16 @@
|
|||||||
# agenthub-provision create <name> Linux user ah-<name>, home <USERS_ROOT>/<name>
|
# agenthub-provision create <name> Linux user ah-<name>, home <USERS_ROOT>/<name>
|
||||||
# agenthub-provision disable <name> lock the account and end its processes
|
# agenthub-provision disable <name> lock the account and end its processes
|
||||||
# agenthub-provision enable <name> unlock it
|
# agenthub-provision enable <name> unlock it
|
||||||
|
# agenthub-provision delete <name> end its processes, remove the account
|
||||||
|
# and its home <USERS_ROOT>/<name>
|
||||||
#
|
#
|
||||||
# Nothing but an action and a name comes from the caller; every path comes
|
# Nothing but an action and a name comes from the caller; every path comes
|
||||||
# from /etc/agenthub/server.conf (root's). It never deletes an account or a
|
# from /etc/agenthub/server.conf (root's). It deletes only on `delete` (an
|
||||||
# home, never touches an account that is not an AgentHub member (one that
|
# admin's step-up-confirmed "Delete user" on the Users page): only an AgentHub
|
||||||
# already exists outside group agenthub-members is refused), and never
|
# member's account, and only the home at exactly <USERS_ROOT>/<name> — a real
|
||||||
|
# directory, never a link, removed without following links or leaving its
|
||||||
|
# filesystem. It never touches an account that is not an AgentHub member (one
|
||||||
|
# that already exists outside group agenthub-members is refused), and never
|
||||||
# grants a member sudo.
|
# grants a member sudo.
|
||||||
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -20,7 +25,7 @@ cd / # runuser keeps the cwd: one every account may enter
|
|||||||
|
|
||||||
die() { echo "agenthub-provision: $*" >&2; exit "${2:-1}"; }
|
die() { echo "agenthub-provision: $*" >&2; exit "${2:-1}"; }
|
||||||
[ "$(id -u)" = 0 ] || die "must run as root (through sudo)"
|
[ "$(id -u)" = 0 ] || die "must run as root (through sudo)"
|
||||||
[ $# -eq 2 ] || die "usage: agenthub-provision create|disable|enable <name>" 2
|
[ $# -eq 2 ] || die "usage: agenthub-provision create|disable|enable|delete <name>" 2
|
||||||
|
|
||||||
CONF=/etc/agenthub/server.conf
|
CONF=/etc/agenthub/server.conf
|
||||||
[ -f "$CONF" ] || die "$CONF is missing — run agenthub server install-system first"
|
[ -f "$CONF" ] || die "$CONF is missing — run agenthub server install-system first"
|
||||||
@@ -84,5 +89,30 @@ case "$ACTION" in
|
|||||||
usermod --unlock "$LOGIN" >/dev/null 2>&1 || true
|
usermod --unlock "$LOGIN" >/dev/null 2>&1 || true
|
||||||
echo "enabled $LOGIN"
|
echo "enabled $LOGIN"
|
||||||
;;
|
;;
|
||||||
|
delete)
|
||||||
|
if id "$LOGIN" >/dev/null 2>&1; then
|
||||||
|
is_member || die "$LOGIN is not an AgentHub member — refusing to delete it" 4
|
||||||
|
# Locked first, so nothing new starts while its processes are ended.
|
||||||
|
usermod --lock --expiredate 1 "$LOGIN"
|
||||||
|
for _ in 1 2 3; do
|
||||||
|
pkill -KILL -u "$LOGIN" 2>/dev/null || true
|
||||||
|
pgrep -u "$LOGIN" >/dev/null 2>&1 || break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
userdel "$LOGIN"
|
||||||
|
# Its own user group, if userdel left it and nobody else is in it.
|
||||||
|
if getent group "$LOGIN" >/dev/null 2>&1; then groupdel "$LOGIN" 2>/dev/null || true; fi
|
||||||
|
fi
|
||||||
|
if [ -e "$HOME_DIR" ] || [ -L "$HOME_DIR" ]; then
|
||||||
|
# Exactly <USERS_ROOT>/<name>: a real directory sitting directly in the
|
||||||
|
# (root-owned) users root — never a link a member planted elsewhere.
|
||||||
|
[ -d "$HOME_DIR" ] && [ ! -L "$HOME_DIR" ] || die "$HOME_DIR is not a plain directory" 5
|
||||||
|
ROOT_REAL=$(realpath -e "$USERS_ROOT")
|
||||||
|
[ "$(realpath -e "$HOME_DIR")" = "$ROOT_REAL/$NAME" ] || die "$HOME_DIR resolves elsewhere" 5
|
||||||
|
# rm never follows a link it removes; --one-file-system stays on this disk.
|
||||||
|
rm -rf --one-file-system -- "$HOME_DIR"
|
||||||
|
fi
|
||||||
|
echo "deleted $LOGIN $HOME_DIR"
|
||||||
|
;;
|
||||||
*) die "unknown action: $ACTION" 2 ;;
|
*) die "unknown action: $ACTION" 2 ;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@@ -3186,6 +3186,48 @@ describe("GET /api/sessions/:id/file", () => {
|
|||||||
fs.rmSync(dir, { recursive: true, force: true });
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
assert.equal((await fetch(url(report))).status, 404);
|
assert.equal((await fetch(url(report))).status, 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("serves a file the session's Bash or reply named as a whole path (issue #76)", async () => {
|
||||||
|
const sid = "named-file-" + Date.now();
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ah-named-file-"));
|
||||||
|
const report = path.join(dir, "REPORT_ssh_ec2.md");
|
||||||
|
const out = path.join(dir, "out.txt");
|
||||||
|
const chart = path.join(dir, "chart.png");
|
||||||
|
const stem = path.join(dir, "REPORT");
|
||||||
|
const unread = path.join(dir, "locked.md");
|
||||||
|
fs.writeFileSync(report, "# Audit");
|
||||||
|
fs.writeFileSync(out, "generated");
|
||||||
|
fs.writeFileSync(chart, "PNGBYTES");
|
||||||
|
fs.writeFileSync(stem, "stem");
|
||||||
|
fs.writeFileSync(unread, "locked");
|
||||||
|
fs.chmodSync(unread, 0o000);
|
||||||
|
await post("/api/hooks/event", { hook_type: "SessionStart", data: { session_id: sid } });
|
||||||
|
await post("/api/hooks/event", {
|
||||||
|
hook_type: "PostToolUse",
|
||||||
|
data: {
|
||||||
|
session_id: sid,
|
||||||
|
tool_name: "Bash",
|
||||||
|
tool_input: { command: `python gen.py -o ${out} && plot ${chart} ${unread}` },
|
||||||
|
tool_response: { stdout: "done", stderr: "" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await post("/api/hooks/event", {
|
||||||
|
hook_type: "Stop",
|
||||||
|
data: { session_id: sid, last_assistant_message: `Báo cáo: \`${report}\`.` },
|
||||||
|
});
|
||||||
|
const url = (p) => `/api/sessions/${sid}/file?path=${encodeURIComponent(p)}`;
|
||||||
|
|
||||||
|
assert.deepEqual((await fetch(url(report))).body, { text: "# Audit", truncated: false });
|
||||||
|
assert.deepEqual((await fetch(url(out))).body, { text: "generated", truncated: false });
|
||||||
|
const img = await fetch(`/api/sessions/${sid}/image?path=${encodeURIComponent(chart)}`);
|
||||||
|
assert.equal(img.status, 200);
|
||||||
|
// Only a WHOLE path: `<dir>/REPORT` is a prefix of the named report, not named itself.
|
||||||
|
assert.equal((await fetch(url(stem))).status, 404);
|
||||||
|
// Named, but its account could not read it (root sees through the mode).
|
||||||
|
if (process.getuid?.() !== 0) assert.equal((await fetch(url(unread))).status, 404);
|
||||||
|
fs.chmodSync(unread, 0o600);
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("GET /api/sessions/:id/image", () => {
|
describe("GET /api/sessions/:id/image", () => {
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests for the Artifacts hub (server/lib/artifacts.js, routes/artifacts.js):
|
||||||
|
* claude.ai pages (link allowlist, hook capture, one row per URL), workspace
|
||||||
|
* file candidates and pins (a snapshot, served sandboxed), skills (snapshot,
|
||||||
|
* symlink/secret refusal, install into the caller's Claude home with
|
||||||
|
* replace moving the old one aside), plugins (marketplace allowlist, install
|
||||||
|
* argv), sharing refused in local mode, visibility for a server-mode
|
||||||
|
* developer, the admin's installs switch, and same-origin writes.
|
||||||
|
* @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.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "artifacts-")));
|
||||||
|
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";
|
||||||
|
|
||||||
|
const { createApp } = require("../index");
|
||||||
|
const { db } = require("../db");
|
||||||
|
const lanesLib = require("../lib/lanes");
|
||||||
|
const artifacts = require("../lib/artifacts");
|
||||||
|
|
||||||
|
const ws = path.join(ROOT, "ws");
|
||||||
|
let lane;
|
||||||
|
let server;
|
||||||
|
let base;
|
||||||
|
|
||||||
|
function request(method, p, body, headers = {}) {
|
||||||
|
return fetch(`${base}${p}`, {
|
||||||
|
method,
|
||||||
|
headers: { "Content-Type": "application/json", ...headers },
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
}).then(async (r) => ({
|
||||||
|
status: r.status,
|
||||||
|
headers: r.headers,
|
||||||
|
text: await r.text(),
|
||||||
|
})).then((r) => {
|
||||||
|
let json = null;
|
||||||
|
try {
|
||||||
|
json = JSON.parse(r.text);
|
||||||
|
} catch {
|
||||||
|
/* not JSON */
|
||||||
|
}
|
||||||
|
return { ...r, body: json };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
fs.mkdirSync(path.join(ws, "out"), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(ws, "out", "report.html"), "<h1>hi</h1><script>x()</script>");
|
||||||
|
fs.writeFileSync(path.join(ws, "main.js"), "code");
|
||||||
|
const skill = path.join(ws, ".claude", "skills", "deploy");
|
||||||
|
fs.mkdirSync(path.join(skill, "scripts"), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(skill, "SKILL.md"), "---\nname: deploy\ndescription: Ship it\n---\nDo it.\n");
|
||||||
|
fs.writeFileSync(path.join(skill, "scripts", "run.sh"), "echo hi\n");
|
||||||
|
fs.mkdirSync(process.env.CLAUDE_HOME, { recursive: true });
|
||||||
|
lane = lanesLib.createLane({ cwd: ws, title: "ws" });
|
||||||
|
db.prepare("INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES ('s1', 's1', 'active', ?, ?)").run(
|
||||||
|
ws,
|
||||||
|
lane.id
|
||||||
|
);
|
||||||
|
const ev = db.prepare(
|
||||||
|
"INSERT INTO events (session_id, event_type, tool_name, data) VALUES ('s1', 'PostToolUse', ?, ?)"
|
||||||
|
);
|
||||||
|
ev.run("Write", JSON.stringify({ tool_input: { file_path: path.join(ws, "out", "report.html") } }));
|
||||||
|
ev.run("Edit", JSON.stringify({ tool_input: { file_path: path.join(ws, "main.js") } }));
|
||||||
|
ev.run("Write", JSON.stringify({ tool_input: { file_path: "/etc/passwd.md" } }));
|
||||||
|
server = http.createServer(createApp());
|
||||||
|
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||||
|
base = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
});
|
||||||
|
after(async () => {
|
||||||
|
await new Promise((r) => server.close(r));
|
||||||
|
fs.rmSync(ROOT, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pages", () => {
|
||||||
|
it("accepts only a claude.ai link, and keeps one row per link", async () => {
|
||||||
|
for (const url of ["http://claude.ai/x", "https://evil.example/claude.ai/", "javascript:x"]) {
|
||||||
|
const r = await request("POST", "/api/artifacts/page", { url });
|
||||||
|
assert.equal(r.status, 400, url);
|
||||||
|
assert.equal(r.body.error.code, "EBADURL");
|
||||||
|
}
|
||||||
|
const url = "https://claude.ai/code/artifact/abc-123";
|
||||||
|
const a = await request("POST", "/api/artifacts/page", { url, title: "One" });
|
||||||
|
assert.equal(a.status, 201);
|
||||||
|
const b = await request("POST", "/api/artifacts/page", { url, title: "Two" });
|
||||||
|
assert.equal(b.body.item.id, a.body.item.id);
|
||||||
|
assert.equal(b.body.item.title, "Two");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("captures a publish from the Artifact tool's hook, owned by the session's workspace", () => {
|
||||||
|
const row = artifacts.captureFromHook("s1", {
|
||||||
|
tool_input: { action: "publish", file_path: "/x/plan.html" },
|
||||||
|
tool_response: "Published: https://claude.ai/artifact/zz9-Q\nWatching it.",
|
||||||
|
});
|
||||||
|
assert.equal(row.url, "https://claude.ai/artifact/zz9-Q");
|
||||||
|
assert.equal(row.title, "plan.html");
|
||||||
|
assert.equal(row.lane_id, lane.id);
|
||||||
|
assert.equal(artifacts.captureFromHook("s1", { tool_input: { action: "read" } }), null);
|
||||||
|
assert.equal(artifacts.captureFromHook("s1", { tool_response: "no link" }), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("files", () => {
|
||||||
|
it("lists only documents a session wrote inside the workspace", async () => {
|
||||||
|
const r = await request("GET", `/api/artifacts/candidates?laneId=${lane.id}`);
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.deepEqual(
|
||||||
|
r.body.items.map((i) => i.path),
|
||||||
|
["out/report.html"]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pins a snapshot and serves it sandboxed, never a path outside it", async () => {
|
||||||
|
const r = await request("POST", "/api/artifacts/file", { laneId: lane.id, path: "out/report.html" });
|
||||||
|
assert.equal(r.status, 201, r.text);
|
||||||
|
const id = r.body.item.id;
|
||||||
|
// The live file changes; the artifact does not.
|
||||||
|
fs.writeFileSync(path.join(ws, "out", "report.html"), "changed");
|
||||||
|
const raw = await request("GET", `/api/artifacts/${id}/raw`);
|
||||||
|
assert.equal(raw.status, 200);
|
||||||
|
assert.match(raw.text, /<h1>hi<\/h1>/);
|
||||||
|
assert.match(raw.headers.get("content-security-policy"), /sandbox/);
|
||||||
|
assert.equal(raw.headers.get("x-content-type-options"), "nosniff");
|
||||||
|
const other = await request("GET", `/api/artifacts/${id}/raw?path=../../t.db`);
|
||||||
|
assert.equal(other.status, 404);
|
||||||
|
|
||||||
|
const escape = await request("POST", "/api/artifacts/file", { laneId: lane.id, path: "../t.db" });
|
||||||
|
assert.equal(escape.status, 400);
|
||||||
|
|
||||||
|
const del = await request("DELETE", `/api/artifacts/${id}`);
|
||||||
|
assert.equal(del.status, 200);
|
||||||
|
assert.equal((await request("GET", `/api/artifacts/${id}`)).status, 404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("skills", () => {
|
||||||
|
it("snapshots a workspace skill and installs it into the caller's Claude home", async () => {
|
||||||
|
const list = await request("GET", `/api/artifacts/skills?laneId=${lane.id}`);
|
||||||
|
assert.deepEqual(list.body.items, [{ name: "deploy", from: "workspace" }]);
|
||||||
|
const r = await request("POST", "/api/artifacts/skill", { laneId: lane.id, name: "deploy" });
|
||||||
|
assert.equal(r.status, 201, r.text);
|
||||||
|
assert.equal(r.body.item.description, "Ship it");
|
||||||
|
assert.deepEqual(
|
||||||
|
r.body.item.files.map((f) => f.path).sort(),
|
||||||
|
["SKILL.md", "scripts/run.sh"]
|
||||||
|
);
|
||||||
|
const id = r.body.item.id;
|
||||||
|
|
||||||
|
const first = await request("POST", `/api/artifacts/${id}/install`, {});
|
||||||
|
assert.equal(first.status, 200, first.text);
|
||||||
|
const target = path.join(process.env.CLAUDE_HOME, "skills", "deploy");
|
||||||
|
assert.equal(fs.readFileSync(path.join(target, "scripts", "run.sh"), "utf8"), "echo hi\n");
|
||||||
|
|
||||||
|
const again = await request("POST", `/api/artifacts/${id}/install`, {});
|
||||||
|
assert.equal(again.status, 409);
|
||||||
|
assert.equal(again.body.error.code, "EEXISTS");
|
||||||
|
fs.writeFileSync(path.join(target, "mine.txt"), "keep me");
|
||||||
|
const replaced = await request("POST", `/api/artifacts/${id}/install`, { replace: true });
|
||||||
|
assert.equal(replaced.status, 200, replaced.text);
|
||||||
|
assert.ok(!fs.existsSync(path.join(target, "mine.txt")));
|
||||||
|
const aside = path.join(process.env.CLAUDE_HOME, "agenthub-replaced-skills");
|
||||||
|
const [old] = fs.readdirSync(aside);
|
||||||
|
assert.equal(fs.readFileSync(path.join(aside, old, "mine.txt"), "utf8"), "keep me");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a skill holding a symlink or a secret-looking file", async () => {
|
||||||
|
const bad = path.join(ws, ".claude", "skills", "leaky");
|
||||||
|
fs.mkdirSync(bad, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(bad, "SKILL.md"), "x");
|
||||||
|
fs.symlinkSync("/etc/passwd", path.join(bad, "link"));
|
||||||
|
const r = await request("POST", "/api/artifacts/skill", { laneId: lane.id, name: "leaky" });
|
||||||
|
assert.equal(r.body.error.code, "ESYMLINK");
|
||||||
|
fs.unlinkSync(path.join(bad, "link"));
|
||||||
|
fs.writeFileSync(path.join(bad, ".env"), "KEY=1");
|
||||||
|
const s = await request("POST", "/api/artifacts/skill", { laneId: lane.id, name: "leaky" });
|
||||||
|
assert.equal(s.body.error.code, "ESECRET");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not installed while an admin has turned installs off", async () => {
|
||||||
|
const item = (await request("GET", "/api/artifacts?kind=skill")).body.items[0];
|
||||||
|
const off = await request("PUT", "/api/artifacts/settings", { installsEnabled: false });
|
||||||
|
assert.equal(off.body.installsEnabled, false);
|
||||||
|
const r = await request("POST", `/api/artifacts/${item.id}/install`, { replace: true });
|
||||||
|
assert.equal(r.status, 403);
|
||||||
|
assert.equal(r.body.error.code, "EINSTALLSOFF");
|
||||||
|
await request("PUT", "/api/artifacts/settings", { installsEnabled: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("plugins", () => {
|
||||||
|
it("takes only name@marketplace from owner/repo or a remote URL, and installs without -y", async () => {
|
||||||
|
const bad = await request("POST", "/api/artifacts/plugin", { key: "x@y", market: "/etc" });
|
||||||
|
assert.equal(bad.body.error.code, "EBADMARKET");
|
||||||
|
const dash = await request("POST", "/api/artifacts/plugin", { key: "-x@y", market: "o/r" });
|
||||||
|
assert.equal(dash.body.error.code, "EBADPLUGIN");
|
||||||
|
const r = await request("POST", "/api/artifacts/plugin", { key: "tool@market", market: "o/r" });
|
||||||
|
assert.equal(r.status, 201, r.text);
|
||||||
|
const calls = [];
|
||||||
|
artifacts.__setExecImpl(async (cmd, args) => {
|
||||||
|
calls.push([cmd, ...args]);
|
||||||
|
return { stdout: "" };
|
||||||
|
});
|
||||||
|
const inst = await request("POST", `/api/artifacts/${r.body.item.id}/install`, {});
|
||||||
|
assert.equal(inst.status, 200, inst.text);
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
["claude", "plugin", "marketplace", "add", "o/r"],
|
||||||
|
["claude", "plugin", "install", "--scope", "user", "tool@market"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sharing and visibility", () => {
|
||||||
|
it("refuses to share in local mode", async () => {
|
||||||
|
const item = (await request("GET", "/api/artifacts")).body.items[0];
|
||||||
|
const r = await request("PATCH", `/api/artifacts/${item.id}`, { shared: true });
|
||||||
|
assert.equal(r.status, 400);
|
||||||
|
assert.equal(r.body.error.code, "ELOCALMODE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a server-mode developer their own rows and shared ones only", () => {
|
||||||
|
db.prepare("INSERT INTO artifacts (kind, title, owner_id, url) VALUES ('page', 'a', 7, 'https://claude.ai/a')").run();
|
||||||
|
db.prepare("INSERT INTO artifacts (kind, title, owner_id, url, shared) VALUES ('page', 'b', 8, 'https://claude.ai/b', 1)").run();
|
||||||
|
db.prepare("INSERT INTO artifacts (kind, title, owner_id, url) VALUES ('page', 'c', 8, 'https://claude.ai/c')").run();
|
||||||
|
const dev = { method: "GET", user: { id: 7, role: "developer", username: "d" } };
|
||||||
|
const titles = artifacts.list(dev).map((i) => i.title).sort();
|
||||||
|
assert.deepEqual(titles, ["a", "b"]);
|
||||||
|
assert.deepEqual(artifacts.list(dev, { scope: "hub" }).map((i) => i.title), ["b"]);
|
||||||
|
const hidden = db.prepare("SELECT id FROM artifacts WHERE title = 'c'").get().id;
|
||||||
|
assert.equal(artifacts.canSeeId(dev, hidden), false);
|
||||||
|
const shared = db.prepare("SELECT id FROM artifacts WHERE title = 'b'").get().id;
|
||||||
|
assert.throws(() => artifacts.update(dev, shared, { title: "x" }), { code: "EFORBIDDEN" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a cross-origin write", async () => {
|
||||||
|
const r = await request(
|
||||||
|
"POST",
|
||||||
|
"/api/artifacts/page",
|
||||||
|
{ url: "https://claude.ai/artifact/q" },
|
||||||
|
{ Origin: "https://evil.example" }
|
||||||
|
);
|
||||||
|
assert.equal(r.status, 403);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -150,6 +150,36 @@ describe("dashboard-runs", () => {
|
|||||||
assert.deepEqual(offered(), []);
|
assert.deepEqual(offered(), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not offer a pane that ended by itself while its tmux server ran on", () => {
|
||||||
|
const { db } = require("../db");
|
||||||
|
db.prepare("INSERT INTO lanes (id, title, cwd) VALUES (21, 'w', '/j')").run();
|
||||||
|
const session = db.prepare(
|
||||||
|
"INSERT INTO sessions (id, name, status, cwd) VALUES (?, ?, 'active', '/j')"
|
||||||
|
);
|
||||||
|
for (const id of ["q-codex", "q-signal", "q-crash", "q-reboot"]) session.run(id, id);
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO events (session_id, event_type, data) VALUES ('q-signal', 'SessionEnd', ?)"
|
||||||
|
).run(JSON.stringify({ reason: "other" }));
|
||||||
|
start("agenthub-lane-21", { laneId: 21, sessionId: "q-codex", tool: "codex" });
|
||||||
|
start("agenthub-lane-21-1", { laneId: 21, sessionId: "q-signal" });
|
||||||
|
start("agenthub-lane-21-2", { laneId: 21, sessionId: "q-crash" });
|
||||||
|
start("agenthub-lane-21-3", { laneId: 21, sessionId: "q-reboot", tool: "codex" });
|
||||||
|
|
||||||
|
// The host's tmux has run since long before; lane 21-3's server is gone.
|
||||||
|
const since = (id) => (id === "agenthub-lane-21-3" ? null : 0);
|
||||||
|
runs.reconcileOrphans((id) => !id.startsWith("agenthub-lane-21"), since);
|
||||||
|
const status = (id) => runs.getRun(id).status;
|
||||||
|
assert.equal(status("agenthub-lane-21"), "abandoned", "Codex quit by the user");
|
||||||
|
assert.equal(status("agenthub-lane-21-1"), "abandoned", "closed, tmux ran on");
|
||||||
|
assert.equal(status("agenthub-lane-21-2"), "interrupted", "Claude never said it ended");
|
||||||
|
assert.equal(status("agenthub-lane-21-3"), "interrupted", "died with its server");
|
||||||
|
|
||||||
|
// A server newer than the run restarted under it: interrupted too.
|
||||||
|
start("agenthub-lane-21-4", { laneId: 21, sessionId: "q-codex", tool: "codex" });
|
||||||
|
runs.reconcileOrphans((id) => !id.startsWith("agenthub-lane-21"), () => Date.now());
|
||||||
|
assert.equal(status("agenthub-lane-21-4"), "interrupted");
|
||||||
|
});
|
||||||
|
|
||||||
it("gives a hookless tool's session the lane of the run it started in", () => {
|
it("gives a hookless tool's session the lane of the run it started in", () => {
|
||||||
const { db } = require("../db");
|
const { db } = require("../db");
|
||||||
start("agenthub-lane-7-1", {
|
start("agenthub-lane-7-1", {
|
||||||
|
|||||||
@@ -192,6 +192,54 @@ describe("POST /api/lanes/clone", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("POST /api/lanes/:id/sources/clone", () => {
|
||||||
|
it("clones beside the workspace folder and adds it as an extra folder", async () => {
|
||||||
|
const home = path.join(WORK, "app");
|
||||||
|
fs.mkdirSync(home);
|
||||||
|
const made = await request("POST", "/api/lanes/ensure", { cwd: home });
|
||||||
|
assert.ok([200, 201].includes(made.status), JSON.stringify(made.body));
|
||||||
|
const id = made.body.lane.id;
|
||||||
|
|
||||||
|
const r = await request("POST", `/api/lanes/${id}/sources/clone`, {
|
||||||
|
url: "https://forge.test/o/r.git",
|
||||||
|
name: "lib",
|
||||||
|
// Ignored: the clone always lands beside the workspace folder.
|
||||||
|
parent: path.join(ROOT, "elsewhere"),
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 201, JSON.stringify(r.body));
|
||||||
|
const target = path.join(WORK, "lib");
|
||||||
|
assert.equal(fs.realpathSync(r.body.path), fs.realpathSync(target));
|
||||||
|
assert.equal(fs.readFileSync(path.join(target, "README.md"), "utf8"), "hi\n");
|
||||||
|
const folders = r.body.sources.filter((s) => s.kind === "folder");
|
||||||
|
assert.deepEqual(
|
||||||
|
folders.map((f) => fs.realpathSync(f.path)),
|
||||||
|
[fs.realpathSync(target)]
|
||||||
|
);
|
||||||
|
|
||||||
|
const again = await request("POST", `/api/lanes/${id}/sources/clone`, {
|
||||||
|
url: "https://forge.test/o/r.git",
|
||||||
|
name: "lib",
|
||||||
|
});
|
||||||
|
assert.equal(again.status, 409);
|
||||||
|
assert.equal(again.body.error.code, "EEXISTS");
|
||||||
|
|
||||||
|
const login = await request("POST", `/api/lanes/${id}/sources/clone`, {
|
||||||
|
url: "https://private.test/o/r.git",
|
||||||
|
name: "private-lib",
|
||||||
|
});
|
||||||
|
assert.equal(login.status, 409);
|
||||||
|
assert.equal(login.body.error.code, "ECREDENTIALS");
|
||||||
|
assert.equal(login.body.error.auth.host, "private.test");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers 404 for a workspace that does not exist", async () => {
|
||||||
|
const r = await request("POST", "/api/lanes/99999/sources/clone", {
|
||||||
|
url: "https://forge.test/o/r.git",
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("/api/me/git", () => {
|
describe("/api/me/git", () => {
|
||||||
it("makes a key once, returns only its public half, and deletes it", async () => {
|
it("makes a key once, returns only its public half, and deletes it", async () => {
|
||||||
await request("DELETE", "/api/me/git/ssh-key");
|
await request("DELETE", "/api/me/git/ssh-key");
|
||||||
@@ -219,6 +267,29 @@ describe("/api/me/git", () => {
|
|||||||
assert.equal(del.body.ssh, null);
|
assert.equal(del.body.ssh, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("makes an RSA 4096 key for hosts that refuse ed25519 (AWS CodeCommit), one key at a time", async () => {
|
||||||
|
await request("POST", "/api/me/git/ssh-key", {});
|
||||||
|
const rsa = await request("POST", "/api/me/git/ssh-key", { replace: true, type: "rsa" });
|
||||||
|
assert.equal(rsa.status, 201, JSON.stringify(rsa.body));
|
||||||
|
assert.equal(rsa.body.ssh.type, "rsa");
|
||||||
|
assert.match(rsa.body.ssh.publicKey, /^ssh-rsa AAAAB3NzaC1yc2E/);
|
||||||
|
assert.ok(!JSON.stringify(rsa.body).includes("PRIVATE"));
|
||||||
|
const dir = path.join(ROOT, ".claude", "agenthub-ssh");
|
||||||
|
assert.equal(fs.statSync(path.join(dir, "id_rsa")).mode & 0o777, 0o600);
|
||||||
|
assert.ok(!fs.existsSync(path.join(dir, "id_ed25519")), "the ed25519 key was replaced");
|
||||||
|
const bits = execFileSync("ssh-keygen", ["-lf", path.join(dir, "id_rsa.pub")], {
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
assert.match(bits, /^4096 .*\(RSA\)/);
|
||||||
|
assert.equal(
|
||||||
|
(await request("POST", "/api/me/git/ssh-key", { replace: true, type: "dsa" })).status,
|
||||||
|
201
|
||||||
|
);
|
||||||
|
// An unknown type falls back to the default (the route only passes rsa through).
|
||||||
|
assert.equal((await request("GET", "/api/me/git")).body.ssh.type, "ed25519");
|
||||||
|
await request("DELETE", "/api/me/git/ssh-key");
|
||||||
|
});
|
||||||
|
|
||||||
it("reads and sets the commit author, refusing a bad one", async () => {
|
it("reads and sets the commit author, refusing a bad one", async () => {
|
||||||
const r0 = await request("GET", "/api/me/git");
|
const r0 = await request("GET", "/api/me/git");
|
||||||
assert.deepEqual(r0.body.author, { name: null, email: null });
|
assert.deepEqual(r0.body.author, { name: null, email: null });
|
||||||
@@ -282,10 +353,10 @@ describe("git-identity, member account (S3, stubbed)", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the key and the logins in the member's own home", () => {
|
it("keeps the key and the logins in the member's own home", async () => {
|
||||||
const account = identity.accountForDir(path.join(HOME, "proj"));
|
const account = identity.accountForDir(path.join(HOME, "proj"));
|
||||||
assert.equal(account.kind, "member");
|
assert.equal(account.kind, "member");
|
||||||
identity.generateSshKey(account);
|
await identity.generateSshKey(account);
|
||||||
const key = path.join(HOME, ".config", "agenthub", "ssh", "id_ed25519");
|
const key = path.join(HOME, ".config", "agenthub", "ssh", "id_ed25519");
|
||||||
assert.equal(fs.statSync(key).mode & 0o777, 0o600);
|
assert.equal(fs.statSync(key).mode & 0o777, 0o600);
|
||||||
identity.saveHttps(account, { host: "gitlab.test", username: "an", password: "p@ss/1" });
|
identity.saveHttps(account, { host: "gitlab.test", username: "an", password: "p@ss/1" });
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests for deleting a user (`DELETE /api/users/:id`, auth.deleteUser):
|
||||||
|
* the admin's own password again, never yourself (ESELF) or the last admin
|
||||||
|
* (ELASTADMIN); the user's logins end, their workspaces' records, prefs and
|
||||||
|
* folder go, the audit log keeps the deed — and under S3 the root helper is
|
||||||
|
* asked to `delete` the Linux account and home FIRST, so its refusal leaves
|
||||||
|
* every row in place.
|
||||||
|
* @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 path = require("node:path");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
|
||||||
|
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-user-delete-"));
|
||||||
|
const USERS = path.join(TMP, "users");
|
||||||
|
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||||
|
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||||
|
|
||||||
|
const { createApp, startServer } = require("../index");
|
||||||
|
const { db } = require("../db");
|
||||||
|
const auth = require("../lib/auth");
|
||||||
|
const linux = require("../lib/linux-accounts");
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let BASE;
|
||||||
|
const ids = {};
|
||||||
|
const BOSS_PASS = "admin-pass-1";
|
||||||
|
|
||||||
|
async function call(method, p, { cookie, body } = {}) {
|
||||||
|
const res = await fetch(BASE + p, {
|
||||||
|
method,
|
||||||
|
headers: { "Content-Type": "application/json", ...(cookie ? { cookie } : {}) },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
return { status: res.status, body: await res.json().catch(() => null) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(username, password) {
|
||||||
|
const res = await fetch(BASE + "/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
return res.status === 200 ? res.headers.get("set-cookie").split(";")[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lane = (cwd, owner) =>
|
||||||
|
Number(
|
||||||
|
db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)").run(cwd, owner)
|
||||||
|
.lastInsertRowid
|
||||||
|
);
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
process.env.AGENTHUB_MODE = "server";
|
||||||
|
process.env.AGENTHUB_USERS_ROOT = USERS;
|
||||||
|
fs.mkdirSync(path.join(USERS, "mia", "proj"), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(USERS, "mia", "proj", "a.txt"), "x");
|
||||||
|
server = await startServer(createApp(), 0);
|
||||||
|
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
ids.boss = (await auth.createUser("boss", BOSS_PASS, "admin")).id;
|
||||||
|
ids.mia = (await auth.createUser("mia", "member-pass-1", "developer")).id;
|
||||||
|
ids.miaLane = lane(path.join(USERS, "mia", "proj"), ids.mia);
|
||||||
|
db.prepare("INSERT INTO user_prefs (user_key, prefs) VALUES (?, '{}')").run(`u${ids.mia}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
delete process.env.AGENTHUB_MODE;
|
||||||
|
delete process.env.AGENTHUB_USERS_ROOT;
|
||||||
|
delete process.env.AGENTHUB_SERVER_CONF;
|
||||||
|
linux.__setRunImpl(null);
|
||||||
|
linux.__resetConfCache();
|
||||||
|
server?.close();
|
||||||
|
fs.rmSync(TMP, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Delete user (DELETE /api/users/:id)", () => {
|
||||||
|
it("needs the admin's own password, and never deletes yourself", async () => {
|
||||||
|
const boss = await login("boss", BOSS_PASS);
|
||||||
|
const wrong = await call("DELETE", `/api/users/${ids.mia}`, {
|
||||||
|
cookie: boss,
|
||||||
|
body: { currentPassword: "nope" },
|
||||||
|
});
|
||||||
|
assert.equal(wrong.status, 403);
|
||||||
|
assert.equal(wrong.body.error.code, "EREAUTH");
|
||||||
|
const self = await call("DELETE", `/api/users/${ids.boss}`, {
|
||||||
|
cookie: boss,
|
||||||
|
body: { currentPassword: BOSS_PASS },
|
||||||
|
});
|
||||||
|
assert.equal(self.status, 409);
|
||||||
|
assert.equal(self.body.error.code, "ESELF");
|
||||||
|
assert.ok(db.prepare("SELECT 1 FROM users WHERE id = ?").get(ids.mia));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a developer", async () => {
|
||||||
|
const mia = await login("mia", "member-pass-1");
|
||||||
|
const r = await call("DELETE", `/api/users/${ids.boss}`, {
|
||||||
|
cookie: mia,
|
||||||
|
body: { currentPassword: "member-pass-1" },
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes the user, their logins, workspaces' records, prefs and folder — audited", async () => {
|
||||||
|
const miaCookie = await login("mia", "member-pass-1");
|
||||||
|
const boss = await login("boss", BOSS_PASS);
|
||||||
|
const r = await call("DELETE", `/api/users/${ids.mia}`, {
|
||||||
|
cookie: boss,
|
||||||
|
body: { currentPassword: BOSS_PASS },
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 200, JSON.stringify(r.body));
|
||||||
|
assert.equal(db.prepare("SELECT 1 FROM users WHERE id = ?").get(ids.mia), undefined);
|
||||||
|
assert.equal(db.prepare("SELECT 1 FROM lanes WHERE id = ?").get(ids.miaLane), undefined);
|
||||||
|
assert.equal(
|
||||||
|
db.prepare("SELECT 1 FROM user_prefs WHERE user_key = ?").get(`u${ids.mia}`),
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
assert.ok(!fs.existsSync(path.join(USERS, "mia")), "her folder is gone");
|
||||||
|
// Her old login no longer works.
|
||||||
|
assert.equal((await call("GET", "/api/me/account", { cookie: miaCookie })).status, 401);
|
||||||
|
const audit = db
|
||||||
|
.prepare("SELECT path FROM audit_log WHERE owner_id = ? ORDER BY id DESC")
|
||||||
|
.get(ids.mia);
|
||||||
|
assert.match(audit.path, /^delete mia/);
|
||||||
|
// The same name can be made again, as a new account.
|
||||||
|
const again = await auth.createUser("mia", "member-pass-3", "developer");
|
||||||
|
assert.notEqual(again.id, ids.mia);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never deletes the last admin", () => {
|
||||||
|
assert.throws(() => auth.deleteUser("boss"), { code: "ELASTADMIN" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Delete user under S3", () => {
|
||||||
|
const CONF = path.join(TMP, "server.conf");
|
||||||
|
let calls;
|
||||||
|
before(() => {
|
||||||
|
fs.writeFileSync(CONF, `HOST_USER=h\nHOST_UID=1000\nUSERS_ROOT=${USERS}\n`);
|
||||||
|
process.env.AGENTHUB_SERVER_CONF = CONF;
|
||||||
|
linux.__resetConfCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks the root helper to delete the account and home first", async () => {
|
||||||
|
calls = [];
|
||||||
|
linux.__setRunImpl((argv) => {
|
||||||
|
calls.push(argv);
|
||||||
|
return "ok";
|
||||||
|
});
|
||||||
|
const u = await auth.createUser("leo", "member-pass-2", "developer");
|
||||||
|
calls = [];
|
||||||
|
auth.deleteUser("leo");
|
||||||
|
assert.deepEqual(calls, [["/opt/agenthub/sbin/agenthub-provision", "delete", "leo"]]);
|
||||||
|
assert.equal(db.prepare("SELECT 1 FROM users WHERE id = ?").get(u.id), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps every row when the helper refuses", async () => {
|
||||||
|
linux.__setRunImpl(() => "ok");
|
||||||
|
const u = await auth.createUser("zoe", "member-pass-4", "developer");
|
||||||
|
const zl = lane(path.join(USERS, "zoe", "p"), u.id);
|
||||||
|
linux.__setRunImpl(() => {
|
||||||
|
throw Object.assign(new Error("x"), { stderr: "ah-zoe is not an AgentHub member" });
|
||||||
|
});
|
||||||
|
assert.throws(() => auth.deleteUser("zoe"), { code: "EPROVISION" });
|
||||||
|
assert.ok(db.prepare("SELECT 1 FROM users WHERE id = ?").get(u.id));
|
||||||
|
assert.ok(db.prepare("SELECT 1 FROM lanes WHERE id = ?").get(zl));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -914,6 +914,37 @@ db.exec(`
|
|||||||
CREATE INDEX IF NOT EXISTS idx_lane_sources_lane ON lane_sources(lane_id);
|
CREATE INDEX IF NOT EXISTS idx_lane_sources_lane ON lane_sources(lane_id);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// The Artifacts hub (server/lib/artifacts.js, docs/plans/artifacts/): what
|
||||||
|
// agents produced — a claude.ai page, a file, a skill, a plugin — kept by its
|
||||||
|
// OWNER and, in server mode, shared to every user when `shared` is set. A
|
||||||
|
// file or skill is a snapshot under <data dir>/artifacts/<snapshot>/, never
|
||||||
|
// the owner's live copy. Additive.
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS artifacts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN ('page', 'file', 'skill', 'plugin')),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
owner_id INTEGER,
|
||||||
|
lane_id INTEGER,
|
||||||
|
session_id TEXT,
|
||||||
|
source TEXT,
|
||||||
|
market TEXT,
|
||||||
|
url TEXT,
|
||||||
|
snapshot TEXT,
|
||||||
|
files TEXT,
|
||||||
|
size INTEGER,
|
||||||
|
shared INTEGER NOT NULL DEFAULT 0,
|
||||||
|
shared_at TEXT,
|
||||||
|
installs INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_artifacts_owner ON artifacts(owner_id, kind);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_artifacts_shared ON artifacts(shared, kind);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_artifacts_session ON artifacts(session_id);
|
||||||
|
`);
|
||||||
|
|
||||||
// Seed lane_stage_events from the snapshots that predate it, so upgrading does
|
// Seed lane_stage_events from the snapshots that predate it, so upgrading does
|
||||||
// not read as "every pipeline has never run". Each lane's live `stages` blob
|
// not read as "every pipeline has never run". Each lane's live `stages` blob
|
||||||
// becomes one run; each archived feature becomes one more. That is all the
|
// becomes one run; each archived feature becomes one more. That is all the
|
||||||
|
|||||||
+6
-4
@@ -210,6 +210,7 @@ function createApp() {
|
|||||||
app.use("/api/locks", locksRouter);
|
app.use("/api/locks", locksRouter);
|
||||||
app.use("/api/pets", petsRouter);
|
app.use("/api/pets", petsRouter);
|
||||||
app.use("/api/snapshots", snapshotsRouter);
|
app.use("/api/snapshots", snapshotsRouter);
|
||||||
|
app.use("/api/artifacts", require("./routes/artifacts"));
|
||||||
app.use("/api/alerts", alertsRouter);
|
app.use("/api/alerts", alertsRouter);
|
||||||
app.use("/api/budgets", budgetsRouter);
|
app.use("/api/budgets", budgetsRouter);
|
||||||
app.use("/api/query", queryRouter);
|
app.use("/api/query", queryRouter);
|
||||||
@@ -762,7 +763,7 @@ function startBackgroundServices() {
|
|||||||
if (Number.isFinite(HEAL_MS) && HEAL_MS > 0) {
|
if (Number.isFinite(HEAL_MS) && HEAL_MS > 0) {
|
||||||
const { healAllRunIds } = require("./routes/lanes");
|
const { healAllRunIds } = require("./routes/lanes");
|
||||||
const { reconcileOrphans } = require("./lib/dashboard-runs");
|
const { reconcileOrphans } = require("./lib/dashboard-runs");
|
||||||
const { liveSessions } = require("./lib/tmux");
|
const { liveSessions, serverStartedAt } = require("./lib/tmux");
|
||||||
const anyRun = require("./db").db.prepare(
|
const anyRun = require("./db").db.prepare(
|
||||||
`SELECT EXISTS (SELECT 1 FROM lanes WHERE run_id IS NOT NULL)
|
`SELECT EXISTS (SELECT 1 FROM lanes WHERE run_id IS NOT NULL)
|
||||||
OR EXISTS (SELECT 1 FROM dashboard_runs WHERE status IN ('running', 'spawning')) AS any`
|
OR EXISTS (SELECT 1 FROM dashboard_runs WHERE status IN ('running', 'spawning')) AS any`
|
||||||
@@ -782,7 +783,8 @@ function startBackgroundServices() {
|
|||||||
healAllRunIds(live);
|
healAllRunIds(live);
|
||||||
// A pane that quit (Ctrl+C, `exit`) ends its run record too; one that
|
// A pane that quit (Ctrl+C, `exit`) ends its run record too; one that
|
||||||
// died under its agent (killed tmux, crash) is offered for resume.
|
// died under its agent (killed tmux, crash) is offered for resume.
|
||||||
if (reconcileOrphans((id) => live.has(id)) > 0) broadcast("runs_interrupted", {});
|
if (reconcileOrphans((id) => live.has(id), serverStartedAt) > 0)
|
||||||
|
broadcast("runs_interrupted", {});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("run heal failed:", err.message);
|
console.warn("run heal failed:", err.message);
|
||||||
}
|
}
|
||||||
@@ -841,8 +843,8 @@ function startBackgroundServices() {
|
|||||||
// this process, owns it.
|
// this process, owns it.
|
||||||
try {
|
try {
|
||||||
const { reconcileOrphans } = require("./lib/dashboard-runs");
|
const { reconcileOrphans } = require("./lib/dashboard-runs");
|
||||||
const { hasSession } = require("./lib/tmux");
|
const { hasSession, serverStartedAt } = require("./lib/tmux");
|
||||||
const reconciled = reconcileOrphans(hasSession);
|
const reconciled = reconcileOrphans(hasSession, serverStartedAt);
|
||||||
if (reconciled > 0) {
|
if (reconciled > 0) {
|
||||||
console.log(`[runs] reconciled ${reconciled} orphan run(s) → interrupted/abandoned`);
|
console.log(`[runs] reconciled ${reconciled} orphan run(s) → interrupted/abandoned`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -494,6 +494,7 @@ async function testProxy(input = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
proxiesDir,
|
||||||
listProxies,
|
listProxies,
|
||||||
getProxy,
|
getProxy,
|
||||||
createProxy,
|
createProxy,
|
||||||
|
|||||||
@@ -0,0 +1,782 @@
|
|||||||
|
/**
|
||||||
|
* @file artifacts.js
|
||||||
|
* @description The Artifacts hub (docs/plans/artifacts/README.md): what agents
|
||||||
|
* produce, kept per OWNER and — in server mode — shared to every user.
|
||||||
|
*
|
||||||
|
* Four kinds:
|
||||||
|
* - `page` a claude.ai Artifact a session published: only a link, accepted
|
||||||
|
* only as `https://claude.ai/…`. Captured from the `Artifact`
|
||||||
|
* tool's PostToolUse hook (`captureFromHook`) or pasted by hand.
|
||||||
|
* - `file` a file an agent wrote in a workspace, pinned by the user.
|
||||||
|
* - `skill` a Claude Code skill folder (the user's own, or a workspace's
|
||||||
|
* `.claude/skills/<name>`).
|
||||||
|
* - `plugin` a plugin by `name@marketplace` and its marketplace source; no
|
||||||
|
* files.
|
||||||
|
*
|
||||||
|
* Rules:
|
||||||
|
* - A `file` or `skill` is a SNAPSHOT taken when it is added, written under
|
||||||
|
* `<data dir>/artifacts/<snapshot>/` and never touched again. Every read a
|
||||||
|
* viewer makes is of that copy — no route reads another user's home for
|
||||||
|
* them — and a path from a request only ever picks one of the snapshot's
|
||||||
|
* own recorded files (`snapshotFile`).
|
||||||
|
* - Visibility: in local mode every row; in server mode a developer sees
|
||||||
|
* their own rows plus shared ones, admin and analyst see every row (like
|
||||||
|
* sessions), and only the owner (or an admin) changes or deletes one.
|
||||||
|
* - Installing a shared skill or plugin is the viewer's click, into the
|
||||||
|
* VIEWER's own account only (`installTarget`): a member's home under S3
|
||||||
|
* (written AS them through `write-as`, `claude` run as them through sudo),
|
||||||
|
* the host's Claude home for an admin or in local mode. An existing skill
|
||||||
|
* is never overwritten without `replace`, and the old one is moved aside to
|
||||||
|
* `~/.claude/agenthub-replaced-skills/`, never deleted. A plugin is
|
||||||
|
* installed without `-y`, so one whose marketplace declares an install
|
||||||
|
* command fails rather than runs unseen. An admin can turn installs off
|
||||||
|
* for everyone (`setInstallsEnabled`). Share, unshare and install are
|
||||||
|
* written to `audit_log`.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const crypto = require("node:crypto");
|
||||||
|
const { promisify } = require("node:util");
|
||||||
|
const { getDataDir, getClaudeHome } = require("./claude-home");
|
||||||
|
|
||||||
|
const sql = (q) => require("../db").db.prepare(q);
|
||||||
|
const fail = (code, message, extra = {}) => Object.assign(new Error(message), { code, ...extra });
|
||||||
|
const isServer = () => require("./auth").isServerMode();
|
||||||
|
|
||||||
|
const FILE_MAX_BYTES = 25 * 1024 * 1024;
|
||||||
|
const SKILL_MAX_BYTES = 5 * 1024 * 1024;
|
||||||
|
const SKILL_MAX_FILES = 300;
|
||||||
|
const SKILL_MAX_DEPTH = 6;
|
||||||
|
const TITLE_MAX = 200;
|
||||||
|
const DESCRIPTION_MAX = 2000;
|
||||||
|
|
||||||
|
const KINDS = ["page", "file", "skill", "plugin"];
|
||||||
|
// What the file finder offers to pin: documents, not source code.
|
||||||
|
const DOC_EXT =
|
||||||
|
/\.(html?|md|markdown|mdx|pdf|png|jpe?g|gif|webp|svg|csv|tsv|json|ya?ml|txt|log|xlsx?|docx?|pptx?|ipynb|mp4|webm|mp3|wav|zip)$/i;
|
||||||
|
const PAGE_URL_RE = /^https:\/\/claude\.ai\/[A-Za-z0-9._~\-/?#=&%]+$/;
|
||||||
|
const PAGE_IN_TEXT_RE = /https:\/\/claude\.ai\/(?:code\/)?artifact\/[A-Za-z0-9_-]+[A-Za-z0-9._~\-/?#=&%]*/;
|
||||||
|
const SKILL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
||||||
|
const PLUGIN_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}@[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
||||||
|
const GITHUB_REPO_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}\/[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/;
|
||||||
|
const SNAPSHOT_RE = /^[A-Za-z0-9_-]{32}$/;
|
||||||
|
// A skill may not carry a login or a key: refused, never copied.
|
||||||
|
const SECRET_NAME_RE =
|
||||||
|
/(^\.env(\..*)?$|credential|secret|password|token|\.pem$|\.key$|\.p12$|\.pfx$|^id_(rsa|ed25519|ecdsa|dsa)$|\.netrc$)/i;
|
||||||
|
|
||||||
|
const root = () => path.join(getDataDir(), "artifacts");
|
||||||
|
const installsOffFlag = () => path.join(root(), "installs-off");
|
||||||
|
|
||||||
|
// ── Visibility ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** `{where, params}` over an `artifacts` alias `a`: the rows this request sees. */
|
||||||
|
function visibleWhere(req) {
|
||||||
|
const u = req?.user;
|
||||||
|
if (!u || u.role === "admin" || u.role === "analyst") return { where: "1", params: [] };
|
||||||
|
return { where: "(a.owner_id = ? OR a.shared = 1)", params: [u.id] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSee = (req, row) => {
|
||||||
|
const u = req?.user;
|
||||||
|
if (!row) return false;
|
||||||
|
if (!u || u.role === "admin" || u.role === "analyst") return true;
|
||||||
|
return row.owner_id === u.id || row.shared === 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Only the owner changes a row — or an admin, or anyone in local mode. */
|
||||||
|
const canChange = (req, row) => !req?.user || req.user.role === "admin" || row.owner_id === req.user.id;
|
||||||
|
|
||||||
|
/** For scope.js's PATH_RULES: may this request name artifact `id`? */
|
||||||
|
function canSeeId(req, id) {
|
||||||
|
return canSee(req, sql("SELECT owner_id, shared FROM artifacts WHERE id = ?").get(Number(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function load(req, id) {
|
||||||
|
const row = /^\d+$/.test(String(id))
|
||||||
|
? sql("SELECT * FROM artifacts WHERE id = ?").get(Number(id))
|
||||||
|
: null;
|
||||||
|
if (!canSee(req, row)) throw fail("ENOTFOUND", "artifact not found");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function view(row, req) {
|
||||||
|
if (!row) return null;
|
||||||
|
const owner =
|
||||||
|
row.owner_id == null
|
||||||
|
? null
|
||||||
|
: sql("SELECT username, display_name FROM users WHERE id = ?").get(row.owner_id);
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
kind: row.kind,
|
||||||
|
title: row.title,
|
||||||
|
description: row.description,
|
||||||
|
ownerId: row.owner_id,
|
||||||
|
owner: owner ? owner.display_name || owner.username : null,
|
||||||
|
mine: !req?.user || row.owner_id === req.user.id,
|
||||||
|
canChange: canChange(req, row),
|
||||||
|
laneId: row.lane_id,
|
||||||
|
sessionId: row.session_id,
|
||||||
|
source: row.source,
|
||||||
|
market: row.market,
|
||||||
|
url: row.url,
|
||||||
|
files: row.files ? JSON.parse(row.files) : [],
|
||||||
|
size: row.size,
|
||||||
|
shared: row.shared === 1,
|
||||||
|
sharedAt: row.shared_at,
|
||||||
|
installs: row.installs,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rows this request may see. `scope`: `mine` (own rows; every row in
|
||||||
|
* local mode), `hub` (shared rows, server mode), anything else = all visible.
|
||||||
|
*/
|
||||||
|
function list(req, { scope = "all", kind, q, laneId, sessionId, limit = 200 } = {}) {
|
||||||
|
const v = visibleWhere(req);
|
||||||
|
const where = [v.where];
|
||||||
|
const params = [...v.params];
|
||||||
|
if (scope === "mine" && req?.user) {
|
||||||
|
where.push("a.owner_id = ?");
|
||||||
|
params.push(req.user.id);
|
||||||
|
} else if (scope === "hub") {
|
||||||
|
where.push("a.shared = 1");
|
||||||
|
}
|
||||||
|
if (kind) {
|
||||||
|
if (!KINDS.includes(kind)) throw fail("EBADFIELD", `kind: one of ${KINDS.join(", ")}`);
|
||||||
|
where.push("a.kind = ?");
|
||||||
|
params.push(kind);
|
||||||
|
}
|
||||||
|
if (laneId != null && laneId !== "") {
|
||||||
|
where.push("a.lane_id = ?");
|
||||||
|
params.push(Number(laneId));
|
||||||
|
}
|
||||||
|
if (sessionId) {
|
||||||
|
where.push("a.session_id = ?");
|
||||||
|
params.push(String(sessionId));
|
||||||
|
}
|
||||||
|
if (q && String(q).trim()) {
|
||||||
|
where.push("(a.title LIKE ? OR a.description LIKE ? OR a.source LIKE ?)");
|
||||||
|
const like = `%${String(q).trim().replace(/[%_]/g, (c) => `\\${c}`)}%`;
|
||||||
|
params.push(like, like, like);
|
||||||
|
}
|
||||||
|
const n = Math.min(Math.max(Number(limit) || 200, 1), 500);
|
||||||
|
const rows = sql(
|
||||||
|
`SELECT a.* FROM artifacts a WHERE ${where.join(" AND ")} ORDER BY a.updated_at DESC, a.id DESC LIMIT ${n}`
|
||||||
|
).all(...params);
|
||||||
|
return rows.map((r) => view(r, req));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Small helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function cleanTitle(v, fallback) {
|
||||||
|
const t = typeof v === "string" ? v.trim().slice(0, TITLE_MAX) : "";
|
||||||
|
return t || String(fallback || "Untitled").slice(0, TITLE_MAX);
|
||||||
|
}
|
||||||
|
const cleanDescription = (v) =>
|
||||||
|
typeof v === "string" ? v.trim().slice(0, DESCRIPTION_MAX) : "";
|
||||||
|
|
||||||
|
function insert(fields) {
|
||||||
|
const cols = Object.keys(fields);
|
||||||
|
const info = sql(
|
||||||
|
`INSERT INTO artifacts (${cols.join(", ")}) VALUES (${cols.map(() => "?").join(", ")})`
|
||||||
|
).run(...cols.map((c) => fields[c]));
|
||||||
|
return sql("SELECT * FROM artifacts WHERE id = ?").get(Number(info.lastInsertRowid));
|
||||||
|
}
|
||||||
|
|
||||||
|
function newSnapshotDir() {
|
||||||
|
const id = crypto.randomBytes(24).toString("base64url");
|
||||||
|
const dir = path.join(root(), id);
|
||||||
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||||
|
return { id, dir };
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropSnapshot(id) {
|
||||||
|
if (!id || !SNAPSHOT_RE.test(id)) return;
|
||||||
|
fs.rmSync(path.join(root(), id), { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function laneOf(laneId) {
|
||||||
|
const lane = require("./lanes").getLane(Number(laneId));
|
||||||
|
if (!lane) throw fail("ENOLANE", "workspace not found");
|
||||||
|
return lane;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The owner of a session's workspace (null: unknown or local mode). */
|
||||||
|
function sessionOwner(sessionId) {
|
||||||
|
const owners = [...require("./scope").sessionOwners(sessionId)];
|
||||||
|
return owners.length ? owners[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A session's workspace, if one owns it. */
|
||||||
|
function sessionLane(sessionId) {
|
||||||
|
const row =
|
||||||
|
sql("SELECT lane_id FROM sessions WHERE id = ?").get(String(sessionId)) ||
|
||||||
|
sql("SELECT lane_id FROM dashboard_runs WHERE session_id = ? AND lane_id IS NOT NULL LIMIT 1").get(
|
||||||
|
String(sessionId)
|
||||||
|
);
|
||||||
|
return row?.lane_id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── page ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function validPageUrl(url) {
|
||||||
|
const u = typeof url === "string" ? url.trim() : "";
|
||||||
|
if (!PAGE_URL_RE.test(u) || u.length > 2000) {
|
||||||
|
throw fail("EBADURL", "a claude.ai link (https://claude.ai/…) is required");
|
||||||
|
}
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add (or retitle) a claude.ai page by hand. */
|
||||||
|
function addPage(req, { url, title, description, laneId, sessionId }) {
|
||||||
|
const u = validPageUrl(url);
|
||||||
|
const ownerId = req?.user ? req.user.id : null;
|
||||||
|
const same = sql(
|
||||||
|
"SELECT * FROM artifacts WHERE kind = 'page' AND url = ? AND owner_id IS ?"
|
||||||
|
).get(u, ownerId);
|
||||||
|
if (same) {
|
||||||
|
sql(
|
||||||
|
"UPDATE artifacts SET title = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
|
||||||
|
).run(cleanTitle(title, same.title), same.id);
|
||||||
|
return view(sql("SELECT * FROM artifacts WHERE id = ?").get(same.id), req);
|
||||||
|
}
|
||||||
|
return view(
|
||||||
|
insert({
|
||||||
|
kind: "page",
|
||||||
|
title: cleanTitle(title, u.replace(/^https:\/\//, "")),
|
||||||
|
description: cleanDescription(description),
|
||||||
|
owner_id: ownerId,
|
||||||
|
lane_id: laneId != null && laneId !== "" ? Number(laneId) : null,
|
||||||
|
session_id: sessionId ? String(sessionId) : null,
|
||||||
|
url: u,
|
||||||
|
}),
|
||||||
|
req
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `Artifact` tool's PostToolUse hook: a publish answers with the page's
|
||||||
|
* claude.ai URL somewhere in its response. Recorded once per (owner, url),
|
||||||
|
* owned by the session's workspace owner. Returns the row or null.
|
||||||
|
*/
|
||||||
|
function captureFromHook(sessionId, data) {
|
||||||
|
const input = data?.tool_input || {};
|
||||||
|
if (input.action && input.action !== "publish") return null;
|
||||||
|
const text = JSON.stringify(data?.tool_response ?? "");
|
||||||
|
const m = PAGE_IN_TEXT_RE.exec(text);
|
||||||
|
if (!m) return null;
|
||||||
|
const url = m[0].replace(/[\\"]+$/, "");
|
||||||
|
if (!PAGE_URL_RE.test(url)) return null;
|
||||||
|
const ownerId = sessionOwner(sessionId);
|
||||||
|
const same = sql(
|
||||||
|
"SELECT * FROM artifacts WHERE kind = 'page' AND url = ? AND owner_id IS ?"
|
||||||
|
).get(url, ownerId);
|
||||||
|
if (same) return same;
|
||||||
|
const file = typeof input.file_path === "string" ? input.file_path : "";
|
||||||
|
return insert({
|
||||||
|
kind: "page",
|
||||||
|
title: cleanTitle(input.title, file ? path.basename(file) : "Artifact"),
|
||||||
|
description: cleanDescription(input.description),
|
||||||
|
owner_id: ownerId,
|
||||||
|
lane_id: sessionLane(sessionId),
|
||||||
|
session_id: String(sessionId),
|
||||||
|
source: file ? file.slice(0, 1000) : null,
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── file ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Files the workspace's sessions wrote (Write/Edit/MultiEdit/NotebookEdit),
|
||||||
|
* documents only, inside the workspace folder, newest first — what the user
|
||||||
|
* can pin. `pinned` marks the ones already kept.
|
||||||
|
*/
|
||||||
|
function fileCandidates(laneId) {
|
||||||
|
const lane = laneOf(laneId);
|
||||||
|
const rows = sql(
|
||||||
|
`SELECT session_id,
|
||||||
|
COALESCE(json_extract(data, '$.tool_input.file_path'),
|
||||||
|
json_extract(data, '$.tool_input.notebook_path')) AS fp,
|
||||||
|
created_at
|
||||||
|
FROM events
|
||||||
|
WHERE event_type = 'PostToolUse'
|
||||||
|
AND tool_name IN ('Write', 'Edit', 'MultiEdit', 'NotebookEdit')
|
||||||
|
AND session_id IN (SELECT id FROM sessions WHERE lane_id = ?
|
||||||
|
UNION SELECT session_id FROM dashboard_runs
|
||||||
|
WHERE lane_id = ? AND session_id IS NOT NULL)
|
||||||
|
ORDER BY id DESC LIMIT 5000`
|
||||||
|
).all(lane.id, lane.id);
|
||||||
|
const pinned = new Set(
|
||||||
|
sql("SELECT source FROM artifacts WHERE kind = 'file' AND lane_id = ?")
|
||||||
|
.all(lane.id)
|
||||||
|
.map((r) => r.source)
|
||||||
|
);
|
||||||
|
const seen = new Set();
|
||||||
|
const out = [];
|
||||||
|
for (const r of rows) {
|
||||||
|
if (typeof r.fp !== "string" || !path.isAbsolute(r.fp) || !DOC_EXT.test(r.fp)) continue;
|
||||||
|
const rel = path.relative(lane.cwd, r.fp);
|
||||||
|
if (!rel || rel.startsWith("..") || path.isAbsolute(rel) || seen.has(rel)) continue;
|
||||||
|
seen.add(rel);
|
||||||
|
let exists = false;
|
||||||
|
try {
|
||||||
|
exists = fs.lstatSync(r.fp).isFile();
|
||||||
|
} catch {
|
||||||
|
exists = false;
|
||||||
|
}
|
||||||
|
out.push({
|
||||||
|
path: rel.split(path.sep).join("/"),
|
||||||
|
sessionId: r.session_id,
|
||||||
|
at: r.created_at,
|
||||||
|
exists,
|
||||||
|
pinned: pinned.has(rel.split(path.sep).join("/")),
|
||||||
|
});
|
||||||
|
if (out.length >= 200) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pin one file of a workspace: a snapshot of it, under the read path's guards. */
|
||||||
|
async function pinFile(req, { laneId, path: rel, title, description, sessionId }) {
|
||||||
|
const lane = laneOf(laneId);
|
||||||
|
const { openDownload } = require("./lane-file-transfer");
|
||||||
|
const file = await openDownload(lane.cwd, typeof rel === "string" ? rel : "");
|
||||||
|
if (file.size > FILE_MAX_BYTES) {
|
||||||
|
file.stream.destroy();
|
||||||
|
throw fail("ETOOBIG", "a file artifact is at most 25 MB");
|
||||||
|
}
|
||||||
|
const snap = newSnapshotDir();
|
||||||
|
const name = file.name;
|
||||||
|
try {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const out = fs.createWriteStream(path.join(snap.dir, name), { mode: 0o600 });
|
||||||
|
file.stream.on("error", reject);
|
||||||
|
out.on("error", reject);
|
||||||
|
out.on("finish", resolve);
|
||||||
|
file.stream.pipe(out);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
dropSnapshot(snap.id);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const relPosix = require("./git-diff").lanePath(rel);
|
||||||
|
return view(
|
||||||
|
insert({
|
||||||
|
kind: "file",
|
||||||
|
title: cleanTitle(title, name),
|
||||||
|
description: cleanDescription(description),
|
||||||
|
owner_id: req?.user ? req.user.id : lane.owner_id ?? null,
|
||||||
|
lane_id: lane.id,
|
||||||
|
session_id: sessionId ? String(sessionId) : null,
|
||||||
|
source: relPosix,
|
||||||
|
snapshot: snap.id,
|
||||||
|
files: JSON.stringify([{ path: name, size: file.size }]),
|
||||||
|
size: file.size,
|
||||||
|
}),
|
||||||
|
req
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── skill ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account a request's own Claude config lives in: `{acct, claudeHome}`.
|
||||||
|
* A member under S3 (their home), else the host's Claude home — only in
|
||||||
|
* local mode or for an admin, since it is the host's own configuration.
|
||||||
|
*/
|
||||||
|
function ownAccount(req) {
|
||||||
|
if (!req?.user) return { acct: null, claudeHome: getClaudeHome() };
|
||||||
|
const runAs = require("./run-as");
|
||||||
|
let acct = null;
|
||||||
|
try {
|
||||||
|
acct = runAs.accountForUser(req.user.username);
|
||||||
|
} catch {
|
||||||
|
acct = null;
|
||||||
|
}
|
||||||
|
if (acct) return { acct, claudeHome: path.join(acct.home, ".claude") };
|
||||||
|
if (req.user.role === "admin") return { acct: null, claudeHome: getClaudeHome() };
|
||||||
|
throw fail(
|
||||||
|
"ENOACCOUNT",
|
||||||
|
"your account has no Claude config of its own on this server (one Linux user per account is not set up)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Skill folders the request may share: its own and the workspace's. */
|
||||||
|
function availableSkills(req, laneId) {
|
||||||
|
const out = [];
|
||||||
|
const scan = (dir, from) => {
|
||||||
|
let names = [];
|
||||||
|
try {
|
||||||
|
names = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const d of names) {
|
||||||
|
if (!d.isDirectory() || !SKILL_NAME_RE.test(d.name)) continue;
|
||||||
|
try {
|
||||||
|
if (fs.lstatSync(path.join(dir, d.name, "SKILL.md")).isFile()) {
|
||||||
|
out.push({ name: d.name, from });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* not a skill */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
scan(path.join(ownAccount(req).claudeHome, "skills"), "user");
|
||||||
|
} catch {
|
||||||
|
/* no own config: only the workspace's */
|
||||||
|
}
|
||||||
|
if (laneId != null && laneId !== "") {
|
||||||
|
scan(path.join(laneOf(laneId).cwd, ".claude", "skills"), "workspace");
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every regular file under `dir`, capped; a symlink is refused, never followed. */
|
||||||
|
function walkSkill(dir) {
|
||||||
|
const files = [];
|
||||||
|
let total = 0;
|
||||||
|
const walk = (abs, rel, depth) => {
|
||||||
|
if (depth > SKILL_MAX_DEPTH) throw fail("ETOOBIG", "the skill folder is nested too deep");
|
||||||
|
for (const d of fs.readdirSync(abs, { withFileTypes: true })) {
|
||||||
|
if (d.name === ".git" || d.name === "node_modules") continue;
|
||||||
|
const childAbs = path.join(abs, d.name);
|
||||||
|
const childRel = rel ? `${rel}/${d.name}` : d.name;
|
||||||
|
const st = fs.lstatSync(childAbs);
|
||||||
|
if (st.isSymbolicLink()) throw fail("ESYMLINK", `the skill holds a symlink: ${childRel}`);
|
||||||
|
if (st.isDirectory()) {
|
||||||
|
walk(childAbs, childRel, depth + 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!st.isFile()) continue;
|
||||||
|
if (SECRET_NAME_RE.test(d.name)) {
|
||||||
|
throw fail("ESECRET", `the skill holds a file that looks like a secret: ${childRel}`);
|
||||||
|
}
|
||||||
|
total += st.size;
|
||||||
|
if (files.length >= SKILL_MAX_FILES) throw fail("ETOOBIG", "the skill has too many files");
|
||||||
|
if (total > SKILL_MAX_BYTES) throw fail("ETOOBIG", "the skill is larger than 5 MB");
|
||||||
|
files.push({ abs: childAbs, path: childRel, size: st.size });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(dir, "", 0);
|
||||||
|
return { files, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
const inside = (parent, child) => {
|
||||||
|
const rel = path.relative(parent, child);
|
||||||
|
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Snapshot one skill folder — the user's own (`laneId` absent) or a workspace's. */
|
||||||
|
function addSkill(req, { name, laneId, title, description }) {
|
||||||
|
if (typeof name !== "string" || !SKILL_NAME_RE.test(name)) {
|
||||||
|
throw fail("EBADNAME", "a skill folder name");
|
||||||
|
}
|
||||||
|
let base;
|
||||||
|
let lane = null;
|
||||||
|
if (laneId != null && laneId !== "") {
|
||||||
|
lane = laneOf(laneId);
|
||||||
|
base = path.join(lane.cwd, ".claude", "skills");
|
||||||
|
} else {
|
||||||
|
base = path.join(ownAccount(req).claudeHome, "skills");
|
||||||
|
}
|
||||||
|
const dir = path.join(base, name);
|
||||||
|
let real;
|
||||||
|
try {
|
||||||
|
real = fs.realpathSync(dir);
|
||||||
|
} catch {
|
||||||
|
throw fail("ENOFILE", "no such skill");
|
||||||
|
}
|
||||||
|
// The folder must really be where its name says (no symlinked skill).
|
||||||
|
const top = lane ? fs.realpathSync(lane.cwd) : fs.realpathSync(path.dirname(base));
|
||||||
|
if (!inside(top, real) || path.basename(real) !== name) {
|
||||||
|
throw fail("ESYMLINK", "the skill folder is a link elsewhere");
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(path.join(real, "SKILL.md"))) throw fail("ENOFILE", "no SKILL.md in that folder");
|
||||||
|
const { files, total } = walkSkill(real);
|
||||||
|
const runAs = require("./run-as");
|
||||||
|
const snap = newSnapshotDir();
|
||||||
|
try {
|
||||||
|
for (const f of files) {
|
||||||
|
const target = path.join(snap.dir, ...f.path.split("/"));
|
||||||
|
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
||||||
|
fs.writeFileSync(target, runAs.readFileSafe(f.abs, null, { max: SKILL_MAX_BYTES }), {
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
dropSnapshot(snap.id);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
let summary = "";
|
||||||
|
try {
|
||||||
|
const head = fs.readFileSync(path.join(snap.dir, "SKILL.md"), "utf8");
|
||||||
|
summary = /^description:\s*(.+)$/m.exec(head)?.[1]?.trim() || "";
|
||||||
|
} catch {
|
||||||
|
/* no frontmatter */
|
||||||
|
}
|
||||||
|
return view(
|
||||||
|
insert({
|
||||||
|
kind: "skill",
|
||||||
|
title: cleanTitle(title, name),
|
||||||
|
description: cleanDescription(description) || summary.slice(0, DESCRIPTION_MAX),
|
||||||
|
owner_id: req?.user ? req.user.id : null,
|
||||||
|
lane_id: lane ? lane.id : null,
|
||||||
|
source: name,
|
||||||
|
snapshot: snap.id,
|
||||||
|
files: JSON.stringify(files.map((f) => ({ path: f.path, size: f.size }))),
|
||||||
|
size: total,
|
||||||
|
}),
|
||||||
|
req
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── plugin ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** A marketplace a plugin may be installed from: `owner/repo` or a remote URL. */
|
||||||
|
function validMarket(market) {
|
||||||
|
const m = typeof market === "string" ? market.trim() : "";
|
||||||
|
if (GITHUB_REPO_RE.test(m)) return m;
|
||||||
|
try {
|
||||||
|
return require("./git-ops").validateRemoteUrl(m);
|
||||||
|
} catch {
|
||||||
|
throw fail("EBADMARKET", "marketplace: owner/repo or an https/ssh git URL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPlugin(req, { key, market, title, description }) {
|
||||||
|
const k = typeof key === "string" ? key.trim() : "";
|
||||||
|
if (!PLUGIN_RE.test(k)) throw fail("EBADPLUGIN", "plugin: name@marketplace");
|
||||||
|
return view(
|
||||||
|
insert({
|
||||||
|
kind: "plugin",
|
||||||
|
title: cleanTitle(title, k),
|
||||||
|
description: cleanDescription(description),
|
||||||
|
owner_id: req?.user ? req.user.id : null,
|
||||||
|
source: k,
|
||||||
|
market: validMarket(market),
|
||||||
|
}),
|
||||||
|
req
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── change / delete ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function audit(req, row, action) {
|
||||||
|
if (!req?.user) return;
|
||||||
|
try {
|
||||||
|
require("./audit").recordAccountChange(
|
||||||
|
req.user.id,
|
||||||
|
row.owner_id ?? null,
|
||||||
|
"POST",
|
||||||
|
`artifact ${action}: #${row.id} ${row.kind} "${row.title}"`
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[artifacts] could not audit:", err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(req, id, body = {}) {
|
||||||
|
const row = load(req, id);
|
||||||
|
if (!canChange(req, row)) throw fail("EFORBIDDEN", "only its owner changes an artifact");
|
||||||
|
const sets = [];
|
||||||
|
const params = [];
|
||||||
|
if ("title" in body) {
|
||||||
|
sets.push("title = ?");
|
||||||
|
params.push(cleanTitle(body.title, row.title));
|
||||||
|
}
|
||||||
|
if ("description" in body) {
|
||||||
|
sets.push("description = ?");
|
||||||
|
params.push(cleanDescription(body.description));
|
||||||
|
}
|
||||||
|
let shareChange = null;
|
||||||
|
if ("shared" in body) {
|
||||||
|
if (typeof body.shared !== "boolean") throw fail("EBADFIELD", "shared: true or false");
|
||||||
|
if (!isServer()) throw fail("ELOCALMODE", "sharing needs server mode");
|
||||||
|
if (body.shared !== (row.shared === 1)) {
|
||||||
|
shareChange = body.shared ? "shared" : "unshared";
|
||||||
|
sets.push("shared = ?", "shared_at = ?");
|
||||||
|
params.push(body.shared ? 1 : 0, body.shared ? new Date().toISOString() : null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!sets.length) return view(row, req);
|
||||||
|
sets.push("updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')");
|
||||||
|
sql(`UPDATE artifacts SET ${sets.join(", ")} WHERE id = ?`).run(...params, row.id);
|
||||||
|
const next = sql("SELECT * FROM artifacts WHERE id = ?").get(row.id);
|
||||||
|
if (shareChange) audit(req, next, shareChange);
|
||||||
|
return view(next, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(req, id) {
|
||||||
|
const row = load(req, id);
|
||||||
|
if (!canChange(req, row)) throw fail("EFORBIDDEN", "only its owner deletes an artifact");
|
||||||
|
sql("DELETE FROM artifacts WHERE id = ?").run(row.id);
|
||||||
|
dropSnapshot(row.snapshot);
|
||||||
|
if (row.shared === 1) audit(req, row, "deleted");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The absolute path of one RECORDED file of a row's snapshot (`rel` must match). */
|
||||||
|
function snapshotFile(req, id, rel) {
|
||||||
|
const row = load(req, id);
|
||||||
|
if (!row.snapshot || !SNAPSHOT_RE.test(row.snapshot)) throw fail("ENOFILE", "no files");
|
||||||
|
const files = row.files ? JSON.parse(row.files) : [];
|
||||||
|
const want = typeof rel === "string" && rel ? rel : files[0]?.path;
|
||||||
|
const hit = files.find((f) => f.path === want);
|
||||||
|
if (!hit) throw fail("ENOFILE", "no such file in this artifact");
|
||||||
|
return { row, abs: path.join(root(), row.snapshot, ...hit.path.split("/")), name: path.basename(hit.path), size: hit.size };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── install ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const installsEnabled = () => !fs.existsSync(installsOffFlag());
|
||||||
|
|
||||||
|
function setInstallsEnabled(on) {
|
||||||
|
fs.mkdirSync(root(), { recursive: true, mode: 0o700 });
|
||||||
|
if (on) fs.rmSync(installsOffFlag(), { force: true });
|
||||||
|
else fs.writeFileSync(installsOffFlag(), "", { mode: 0o600 });
|
||||||
|
return installsEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
function installable(req, row) {
|
||||||
|
if (row.kind !== "skill" && row.kind !== "plugin") {
|
||||||
|
throw fail("EBADKIND", "only a skill or a plugin is installed");
|
||||||
|
}
|
||||||
|
if (!installsEnabled()) throw fail("EINSTALLSOFF", "an admin turned Hub installs off");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Install a skill snapshot into the viewer's own `~/.claude/skills/<name>`. */
|
||||||
|
async function installSkill(req, row, { replace = false } = {}) {
|
||||||
|
const { acct, claudeHome } = ownAccount(req);
|
||||||
|
const runAs = require("./run-as");
|
||||||
|
const skills = path.join(claudeHome, "skills");
|
||||||
|
const target = path.join(skills, row.source);
|
||||||
|
const taken = (() => {
|
||||||
|
try {
|
||||||
|
fs.lstatSync(target);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
if (taken && !replace) throw fail("EEXISTS", `a skill named ${row.source} is already installed`);
|
||||||
|
if (taken) {
|
||||||
|
const aside = path.join(
|
||||||
|
claudeHome,
|
||||||
|
"agenthub-replaced-skills",
|
||||||
|
`${row.source}-${new Date().toISOString().replace(/[:.]/g, "-")}`
|
||||||
|
);
|
||||||
|
if (acct) {
|
||||||
|
if (!runAs.writeAsCanMove()) {
|
||||||
|
throw fail("EUPGRADE", "replacing needs the updated helper: run `sudo agenthub server install-system` once");
|
||||||
|
}
|
||||||
|
runAs.mkdirAs(acct, path.dirname(aside));
|
||||||
|
await runAs.writeAsAsync(acct, target, ["move", aside]);
|
||||||
|
} else {
|
||||||
|
fs.mkdirSync(path.dirname(aside), { recursive: true });
|
||||||
|
fs.renameSync(target, aside);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const files = row.files ? JSON.parse(row.files) : [];
|
||||||
|
for (const f of files) {
|
||||||
|
const from = path.join(root(), row.snapshot, ...f.path.split("/"));
|
||||||
|
const to = path.join(target, ...f.path.split("/"));
|
||||||
|
const content = fs.readFileSync(from);
|
||||||
|
if (acct) {
|
||||||
|
runAs.mkdirAs(acct, path.dirname(to));
|
||||||
|
runAs.writeFileAs(to, content);
|
||||||
|
} else {
|
||||||
|
fs.mkdirSync(path.dirname(to), { recursive: true });
|
||||||
|
fs.writeFileSync(to, content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { installed: target, replaced: taken };
|
||||||
|
}
|
||||||
|
|
||||||
|
let execImpl = promisify(require("node:child_process").execFile);
|
||||||
|
function __setExecImpl(fn) {
|
||||||
|
execImpl = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `claude plugin marketplace add` + `claude plugin install --scope user`, as the viewer. */
|
||||||
|
async function installPlugin(req, row) {
|
||||||
|
const { acct } = ownAccount(req);
|
||||||
|
const steps = [
|
||||||
|
["plugin", "marketplace", "add", validMarket(row.market)],
|
||||||
|
["plugin", "install", "--scope", "user", row.source],
|
||||||
|
];
|
||||||
|
if (!PLUGIN_RE.test(row.source)) throw fail("EBADPLUGIN", "plugin: name@marketplace");
|
||||||
|
let run;
|
||||||
|
if (acct) {
|
||||||
|
const runAs = require("./run-as");
|
||||||
|
const claude = require("./shared-tools").sharedPath("claude");
|
||||||
|
if (!claude) throw fail("ENOTSHARED", "claude is not in the shared tools yet");
|
||||||
|
const env = { HOME: acct.home, DISABLE_AUTOUPDATER: "1" };
|
||||||
|
run = (args) => {
|
||||||
|
const [cmd, argv] = runAs.sudoArgv(acct, claude, args, env);
|
||||||
|
return execImpl(cmd, argv, { cwd: acct.home, timeout: 300_000, encoding: "utf8" });
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const env = { ...process.env, DISABLE_AUTOUPDATER: "1" };
|
||||||
|
if (process.env.CLAUDE_HOME) env.CLAUDE_CONFIG_DIR = getClaudeHome();
|
||||||
|
run = (args) => execImpl("claude", args, { timeout: 300_000, encoding: "utf8", env });
|
||||||
|
}
|
||||||
|
const lastLine = (e) =>
|
||||||
|
String(e?.stderr || e?.stdout || e?.message || "")
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.pop();
|
||||||
|
try {
|
||||||
|
await run(steps[0]);
|
||||||
|
} catch (e) {
|
||||||
|
// Already added is fine; anything else is the answer.
|
||||||
|
if (!/already/i.test(String(e?.stderr || "") + String(e?.stdout || ""))) {
|
||||||
|
throw fail("EPLUGIN", `marketplace: ${lastLine(e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await run(steps[1]);
|
||||||
|
} catch (e) {
|
||||||
|
throw fail("EPLUGIN", `install: ${lastLine(e)}`);
|
||||||
|
}
|
||||||
|
return { installed: row.source };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function install(req, id, opts = {}) {
|
||||||
|
const row = load(req, id);
|
||||||
|
installable(req, row);
|
||||||
|
const result =
|
||||||
|
row.kind === "skill" ? await installSkill(req, row, opts) : await installPlugin(req, row);
|
||||||
|
sql("UPDATE artifacts SET installs = installs + 1 WHERE id = ?").run(row.id);
|
||||||
|
if (req?.user && row.owner_id !== req.user.id) audit(req, row, "installed");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
KINDS,
|
||||||
|
DOC_EXT,
|
||||||
|
list,
|
||||||
|
load,
|
||||||
|
view,
|
||||||
|
canSeeId,
|
||||||
|
addPage,
|
||||||
|
captureFromHook,
|
||||||
|
fileCandidates,
|
||||||
|
pinFile,
|
||||||
|
availableSkills,
|
||||||
|
addSkill,
|
||||||
|
addPlugin,
|
||||||
|
update,
|
||||||
|
remove,
|
||||||
|
snapshotFile,
|
||||||
|
install,
|
||||||
|
installsEnabled,
|
||||||
|
setInstallsEnabled,
|
||||||
|
__setExecImpl,
|
||||||
|
};
|
||||||
+71
-2
@@ -279,8 +279,8 @@ function stopHostPanes(userId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Disable (ending every login) or re-enable a user. Users are never deleted:
|
/** Disable (ending every login) or re-enable a user — the reversible way out;
|
||||||
* S2 will hang owned rows off their id. */
|
* `deleteUser` below is the one that removes an account for good. */
|
||||||
function setDisabled(username, disabled) {
|
function setDisabled(username, disabled) {
|
||||||
const u = requireUser(username);
|
const u = requireUser(username);
|
||||||
const linux = require("./linux-accounts");
|
const linux = require("./linux-accounts");
|
||||||
@@ -298,6 +298,74 @@ function setDisabled(username, disabled) {
|
|||||||
if (disabled && hasAccount) linux.provision("disable", u.username);
|
if (disabled && hasAccount) linux.provision("disable", u.username);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE a user — the Users page's "Delete user" (an admin, password
|
||||||
|
* confirmed by the route). In order, so a failure leaves the account usable
|
||||||
|
* rather than half gone: their logins end and the panes of their workspaces
|
||||||
|
* stop; under S3 the Linux account `ah-<name>` and its whole home are removed
|
||||||
|
* (`agenthub-provision delete`; EPROVISION stops here, with no row touched);
|
||||||
|
* without S3 their own folder under AGENTHUB_USERS_ROOT goes. Then the rows:
|
||||||
|
* their workspaces' records, prefs, push subscriptions, personal AI Proxy
|
||||||
|
* profiles and the user. Session history and the audit log stay. Never the
|
||||||
|
* last enabled admin (ELASTADMIN); the route refuses yourself (ESELF).
|
||||||
|
* A user made again under the same name gets a NEW, empty Linux account.
|
||||||
|
*/
|
||||||
|
function deleteUser(username) {
|
||||||
|
const u = requireUser(username);
|
||||||
|
if (u.role === "admin") {
|
||||||
|
const others = sql(
|
||||||
|
"SELECT COUNT(*) AS n FROM users WHERE role = 'admin' AND disabled = 0 AND id != ?"
|
||||||
|
).get(u.id).n;
|
||||||
|
if (!others) throw err("ELASTADMIN", "the last admin cannot be deleted");
|
||||||
|
}
|
||||||
|
const lanes = sql("SELECT id, cwd FROM lanes WHERE owner_id = ?").all(u.id);
|
||||||
|
sql("DELETE FROM auth_sessions WHERE user_id = ?").run(u.id);
|
||||||
|
const pty = require("./pty-run");
|
||||||
|
for (const lane of lanes) {
|
||||||
|
for (let slot = 0; slot <= pty.MAX_PANE_SLOT; slot++) {
|
||||||
|
try {
|
||||||
|
pty.killRun(pty.runIdForLane(lane.id, slot));
|
||||||
|
} catch {
|
||||||
|
/* not running */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const linux = require("./linux-accounts");
|
||||||
|
if (linux.s3Enabled()) {
|
||||||
|
// Idempotent: a user with no Linux account (made before S3) or no home
|
||||||
|
// still passes; anything the helper refuses stops the delete here.
|
||||||
|
if (linux.LINUX_NAME_RE.test(u.username)) linux.provision("delete", u.username);
|
||||||
|
} else {
|
||||||
|
const path = require("node:path");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const root = (process.env.AGENTHUB_USERS_ROOT || "").trim();
|
||||||
|
if (root && /^[a-z][a-z0-9_-]{0,63}$/i.test(u.username)) {
|
||||||
|
const home = path.join(path.resolve(root), u.username);
|
||||||
|
// Only a real folder directly in the users root — never a link.
|
||||||
|
const st = fs.lstatSync(home, { throwIfNoEntry: false });
|
||||||
|
if (st?.isDirectory()) fs.rmSync(home, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const lanesLib = require("./lanes");
|
||||||
|
for (const lane of lanes) lanesLib.deleteLane(lane.id);
|
||||||
|
sql("DELETE FROM user_prefs WHERE user_key = ?").run(`u${u.id}`);
|
||||||
|
try {
|
||||||
|
sql("DELETE FROM push_subscriptions WHERE user_id = ?").run(u.id);
|
||||||
|
} catch {
|
||||||
|
/* an older database without the column */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
require("node:fs").rmSync(
|
||||||
|
require("node:path").join(require("./ai-proxies").proxiesDir(), "users", String(u.id)),
|
||||||
|
{ recursive: true, force: true }
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
/* no personal profiles */
|
||||||
|
}
|
||||||
|
sql("DELETE FROM users WHERE id = ?").run(u.id);
|
||||||
|
return { id: u.id, username: u.username, workspaces: lanes.length };
|
||||||
|
}
|
||||||
|
|
||||||
/** Give a workspace (a lane id) to a user — for the ones made before server
|
/** Give a workspace (a lane id) to a user — for the ones made before server
|
||||||
* mode, which have no owner and so reach no member. */
|
* mode, which have no owner and so reach no member. */
|
||||||
function assignWorkspace(laneId, username) {
|
function assignWorkspace(laneId, username) {
|
||||||
@@ -603,6 +671,7 @@ module.exports = {
|
|||||||
setPassword,
|
setPassword,
|
||||||
setRole,
|
setRole,
|
||||||
setDisabled,
|
setDisabled,
|
||||||
|
deleteUser,
|
||||||
assignWorkspace,
|
assignWorkspace,
|
||||||
unassignWorkspace,
|
unassignWorkspace,
|
||||||
ownedWorkspaces,
|
ownedWorkspaces,
|
||||||
|
|||||||
@@ -288,6 +288,17 @@ const userExitStmt = db.prepare(`
|
|||||||
LIMIT 1
|
LIMIT 1
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
const anyEndStmt = db.prepare(`
|
||||||
|
SELECT 1 FROM events WHERE session_id = ? AND event_type = 'SessionEnd' LIMIT 1
|
||||||
|
`);
|
||||||
|
// Whether the pane's tmux server was already up when the run started (and is
|
||||||
|
// still): then the pane ended on its own, not with its server.
|
||||||
|
function tmuxOutlived(row, serverStartedAt) {
|
||||||
|
const since = serverStartedAt(row.id);
|
||||||
|
const started = Date.parse(row.started_at || "");
|
||||||
|
return Number.isFinite(since) && Number.isFinite(started) && since <= started;
|
||||||
|
}
|
||||||
|
|
||||||
// A shell has no session, so its row only tracks the open tab: once closed it
|
// A shell has no session, so its row only tracks the open tab: once closed it
|
||||||
// is dropped — the sidebar keeps no list of closed Terminal tabs.
|
// is dropped — the sidebar keeps no list of closed Terminal tabs.
|
||||||
const pruneShellsStmt = db.prepare(`
|
const pruneShellsStmt = db.prepare(`
|
||||||
@@ -303,12 +314,16 @@ const pruneShellsStmt = db.prepare(`
|
|||||||
* A pane AgentHub closed itself (Stop, close tab, Quit) is already `killed`.
|
* A pane AgentHub closed itself (Stop, close tab, Quit) is already `killed`.
|
||||||
* Of the rest, an agent session the user did not exit is `interrupted` — a
|
* Of the rest, an agent session the user did not exit is `interrupted` — a
|
||||||
* reboot, a killed tmux, a crash — and offered for resume
|
* reboot, a killed tmux, a crash — and offered for resume
|
||||||
* (`listInterrupted`); everything else is `abandoned`. Only Claude reports
|
* (`listInterrupted`); everything else is `abandoned`. A pane whose tmux
|
||||||
* how it ended, so any other tool's vanished pane counts as interrupted.
|
* server kept running since before it started (`serverStartedAt(id)`, epoch
|
||||||
|
* ms or null) ended by itself — the user quit Codex, `exit`ed, closed it —
|
||||||
|
* so it is not offered either: for Claude only when it said it ended (any
|
||||||
|
* SessionEnd; none at all is a crash), for any other tool, which says
|
||||||
|
* nothing, always. A server that cannot be asked counts as gone.
|
||||||
*
|
*
|
||||||
* Returns the number of rows updated.
|
* Returns the number of rows updated.
|
||||||
*/
|
*/
|
||||||
function reconcileOrphans(isAlive = () => false) {
|
function reconcileOrphans(isAlive = () => false, serverStartedAt = () => null) {
|
||||||
try {
|
try {
|
||||||
let changes = 0;
|
let changes = 0;
|
||||||
const { unseenMemberFolder } = require("./linux-accounts");
|
const { unseenMemberFolder } = require("./linux-accounts");
|
||||||
@@ -317,7 +332,11 @@ function reconcileOrphans(isAlive = () => false) {
|
|||||||
const interrupted =
|
const interrupted =
|
||||||
row.tool !== "shell" &&
|
row.tool !== "shell" &&
|
||||||
!!row.session_id &&
|
!!row.session_id &&
|
||||||
!(row.tool === "claude" && userExitStmt.get(row.session_id));
|
!(row.tool === "claude" && userExitStmt.get(row.session_id)) &&
|
||||||
|
!(
|
||||||
|
tmuxOutlived(row, serverStartedAt) &&
|
||||||
|
(row.tool !== "claude" || anyEndStmt.get(row.session_id))
|
||||||
|
);
|
||||||
changes += endStmt.run({
|
changes += endStmt.run({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
status: interrupted ? "interrupted" : "abandoned",
|
status: interrupted ? "interrupted" : "abandoned",
|
||||||
|
|||||||
+89
-26
@@ -5,7 +5,7 @@
|
|||||||
*
|
*
|
||||||
* Whose identity is decided by an ACCOUNT, never by the request:
|
* Whose identity is decided by an ACCOUNT, never by the request:
|
||||||
* - a member (S3, `run-as.js`): everything lives in their own home, under
|
* - a member (S3, `run-as.js`): everything lives in their own home, under
|
||||||
* `~/.config/agenthub/` — `ssh/id_ed25519` (+ `.pub`) and `git-credentials`
|
* `~/.config/agenthub/` — `ssh/id_ed25519` or `ssh/id_rsa` (+ `.pub`) and `git-credentials`
|
||||||
* (git's own `credential-store` format) — written only AS the member
|
* (git's own `credential-store` format) — written only AS the member
|
||||||
* (`write-as`, 0600 for the secrets), read only through `readFileSafe`.
|
* (`write-as`, 0600 for the secrets), read only through `readFileSafe`.
|
||||||
* Their git runs as them, so it reads these itself; no secret ever crosses
|
* Their git runs as them, so it reads these itself; no secret ever crosses
|
||||||
@@ -61,18 +61,26 @@ function accountForRequest(req) {
|
|||||||
return { kind: "host", writable: !user || user.role === "admin" };
|
return { kind: "host", writable: !user || user.role === "admin" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The key types offered: ed25519 (the default — GitHub, GitLab, Gitea…) and
|
||||||
|
* RSA 4096 for hosts that refuse ed25519 (AWS IAM / CodeCommit takes only
|
||||||
|
* `ssh-rsa`). One key per account at a time, in the file of its type.
|
||||||
|
*/
|
||||||
|
const KEY_FILES = { ed25519: "id_ed25519", rsa: "id_rsa" };
|
||||||
|
const KEY_PREFIX = { ed25519: "ssh-ed25519", rsa: "ssh-rsa" };
|
||||||
|
|
||||||
function paths(account) {
|
function paths(account) {
|
||||||
if (account.kind === "member") {
|
if (account.kind === "member") {
|
||||||
const base = path.join(account.acct.home, ".config", "agenthub");
|
const base = path.join(account.acct.home, ".config", "agenthub");
|
||||||
return {
|
return {
|
||||||
base,
|
base,
|
||||||
sshDir: path.join(base, "ssh"),
|
sshDir: path.join(base, "ssh"),
|
||||||
key: path.join(base, "ssh", "id_ed25519"),
|
key: path.join(base, "ssh", KEY_FILES.ed25519),
|
||||||
store: path.join(base, "git-credentials"),
|
store: path.join(base, "git-credentials"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const sshDir = path.join(getClaudeHome(), "agenthub-ssh");
|
const sshDir = path.join(getClaudeHome(), "agenthub-ssh");
|
||||||
return { base: sshDir, sshDir, key: path.join(sshDir, "id_ed25519"), store: null };
|
return { base: sshDir, sshDir, key: path.join(sshDir, KEY_FILES.ed25519), store: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -130,6 +138,31 @@ const u32 = (n) => {
|
|||||||
};
|
};
|
||||||
const sshString = (buf) => Buffer.concat([u32(buf.length), Buffer.from(buf)]);
|
const sshString = (buf) => Buffer.concat([u32(buf.length), Buffer.from(buf)]);
|
||||||
|
|
||||||
|
/** An SSH mpint: big-endian, with a 0 byte in front when the top bit is set. */
|
||||||
|
const mpint = (b) => sshString(b[0] & 0x80 ? Buffer.concat([Buffer.from([0]), b]) : b);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fresh RSA 4096 pair: the private key as PKCS#1 PEM (`BEGIN RSA PRIVATE
|
||||||
|
* KEY`, which OpenSSH reads), the public one as an `ssh-rsa` line. Made off
|
||||||
|
* the event loop — 4096-bit generation takes a second.
|
||||||
|
*/
|
||||||
|
async function makeRsaKeyPair(comment) {
|
||||||
|
const pair = await require("node:util").promisify(crypto.generateKeyPair)("rsa", {
|
||||||
|
modulusLength: 4096,
|
||||||
|
publicExponent: 0x10001,
|
||||||
|
});
|
||||||
|
const jwk = pair.publicKey.export({ format: "jwk" });
|
||||||
|
const blob = Buffer.concat([
|
||||||
|
sshString(Buffer.from("ssh-rsa")),
|
||||||
|
mpint(Buffer.from(jwk.e, "base64url")),
|
||||||
|
mpint(Buffer.from(jwk.n, "base64url")),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
privateKey: pair.privateKey.export({ type: "pkcs1", format: "pem" }),
|
||||||
|
publicKey: `ssh-rsa ${blob.toString("base64")} ${comment}\n`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A fresh ed25519 pair in OpenSSH's formats: `{privateKey, publicKey}` text,
|
* A fresh ed25519 pair in OpenSSH's formats: `{privateKey, publicKey}` text,
|
||||||
* unencrypted (`openssh-key-v1`, cipher none) — the dashboard has no terminal
|
* unencrypted (`openssh-key-v1`, cipher none) — the dashboard has no terminal
|
||||||
@@ -175,36 +208,65 @@ function fingerprint(publicKey) {
|
|||||||
return `SHA256:${crypto.createHash("sha256").update(blob).digest("base64").replace(/=+$/, "")}`;
|
return `SHA256:${crypto.createHash("sha256").update(blob).digest("base64").replace(/=+$/, "")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `{publicKey, fingerprint}` of the account's key, or null — never the private half. */
|
/** `{type, file}` of the account's key (ed25519 first), or null. */
|
||||||
function sshKeyInfo(account) {
|
function currentKey(account) {
|
||||||
const { key } = paths(account);
|
const { sshDir } = paths(account);
|
||||||
const publicKey = readText(account, `${key}.pub`);
|
for (const type of Object.keys(KEY_FILES)) {
|
||||||
if (!publicKey || !/^ssh-ed25519 [A-Za-z0-9+/=]+/.test(publicKey)) return null;
|
const file = path.join(sshDir, KEY_FILES[type]);
|
||||||
// The private half is only looked at, never read into this process.
|
const publicKey = readText(account, `${file}.pub`);
|
||||||
if (!isOwnFile(account, key)) return null;
|
if (!publicKey || !publicKey.startsWith(`${KEY_PREFIX[type]} `)) continue;
|
||||||
return { publicKey: publicKey.trim(), fingerprint: fingerprint(publicKey) };
|
// The private half is only looked at, never read into this process.
|
||||||
|
if (!isOwnFile(account, file)) continue;
|
||||||
|
return { type, file, publicKey: publicKey.trim() };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Make the account's key; an existing one is replaced only with `replace`. */
|
/** The private key file a clone or fetch uses, or null. */
|
||||||
function generateSshKey(account, { replace = false, comment } = {}) {
|
const keyFile = (account) => currentKey(account)?.file ?? null;
|
||||||
|
|
||||||
|
/** `{type, publicKey, fingerprint}` of the account's key, or null — never the private half. */
|
||||||
|
function sshKeyInfo(account) {
|
||||||
|
const k = currentKey(account);
|
||||||
|
return k ? { type: k.type, publicKey: k.publicKey, fingerprint: fingerprint(k.publicKey) } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make the account's key (`type`: ed25519, the default, or rsa); an existing
|
||||||
|
* one is replaced only with `replace` — and a key of the other type goes, so
|
||||||
|
* there is one key per account.
|
||||||
|
*/
|
||||||
|
async function generateSshKey(account, { replace = false, comment, type = "ed25519" } = {}) {
|
||||||
|
if (!KEY_FILES[type]) throw err("EBADKEYTYPE", "key type: ed25519 or rsa");
|
||||||
if (sshKeyInfo(account) && !replace) {
|
if (sshKeyInfo(account) && !replace) {
|
||||||
throw err("EEXISTS", "an SSH key already exists — replace it explicitly");
|
throw err("EEXISTS", "an SSH key already exists — replace it explicitly");
|
||||||
}
|
}
|
||||||
const label = String(comment || "agenthub")
|
const label =
|
||||||
.replace(/[^\w.@+-]/g, "")
|
String(comment || "agenthub")
|
||||||
.slice(0, 64);
|
.replace(/[^\w.@+-]/g, "")
|
||||||
const { privateKey, publicKey } = makeKeyPair(label || "agenthub");
|
.slice(0, 64) || "agenthub";
|
||||||
const { key } = paths(account);
|
const { privateKey, publicKey } =
|
||||||
writeText(account, key, privateKey, { secret: true });
|
type === "rsa" ? await makeRsaKeyPair(label) : makeKeyPair(label);
|
||||||
writeText(account, `${key}.pub`, publicKey, { secret: false });
|
const { sshDir } = paths(account);
|
||||||
|
const file = path.join(sshDir, KEY_FILES[type]);
|
||||||
|
writeText(account, file, privateKey, { secret: true });
|
||||||
|
writeText(account, `${file}.pub`, publicKey, { secret: false });
|
||||||
|
for (const [other, name] of Object.entries(KEY_FILES)) {
|
||||||
|
if (other === type) continue;
|
||||||
|
const f = path.join(sshDir, name);
|
||||||
|
if (isOwnFile(account, f) || readText(account, `${f}.pub`) != null) {
|
||||||
|
removeFile(account, f);
|
||||||
|
removeFile(account, `${f}.pub`);
|
||||||
|
}
|
||||||
|
}
|
||||||
return sshKeyInfo(account);
|
return sshKeyInfo(account);
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeSshKey(account) {
|
function removeSshKey(account) {
|
||||||
const { key } = paths(account);
|
const k = currentKey(account);
|
||||||
if (!sshKeyInfo(account)) return false;
|
if (!k) return false;
|
||||||
removeFile(account, key);
|
removeFile(account, k.file);
|
||||||
removeFile(account, `${key}.pub`);
|
removeFile(account, `${k.file}.pub`);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,7 +416,7 @@ async function sshConfigFor(dir) {
|
|||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const key = sshKeyInfo(account) ? paths(account).key : null;
|
const key = keyFile(account);
|
||||||
return [
|
return [
|
||||||
"-c",
|
"-c",
|
||||||
`core.sshCommand=ssh${key ? ` -i ${shq(key)}` : ""} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`,
|
`core.sshCommand=ssh${key ? ` -i ${shq(key)}` : ""} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`,
|
||||||
@@ -464,7 +526,7 @@ async function cloneRepo({ url, parent, name, once = null }) {
|
|||||||
|
|
||||||
if (remote.scheme === "ssh") {
|
if (remote.scheme === "ssh") {
|
||||||
if (once) throw err("EBADCRED", "a typed login is for https only — SSH uses your key");
|
if (once) throw err("EBADCRED", "a typed login is for https only — SSH uses your key");
|
||||||
const key = sshKeyInfo(account) ? paths(account).key : null;
|
const key = keyFile(account);
|
||||||
const base = key ? `ssh -i ${shq(key)} -o IdentitiesOnly=yes` : "ssh";
|
const base = key ? `ssh -i ${shq(key)} -o IdentitiesOnly=yes` : "ssh";
|
||||||
// BatchMode: no terminal to answer a prompt; accept-new: first contact
|
// BatchMode: no terminal to answer a prompt; accept-new: first contact
|
||||||
// records the host key, a CHANGED one still fails.
|
// records the host key, a CHANGED one still fails.
|
||||||
@@ -552,6 +614,7 @@ module.exports = {
|
|||||||
accountForDir,
|
accountForDir,
|
||||||
accountForRequest,
|
accountForRequest,
|
||||||
makeKeyPair,
|
makeKeyPair,
|
||||||
|
makeRsaKeyPair,
|
||||||
fingerprint,
|
fingerprint,
|
||||||
sshKeyInfo,
|
sshKeyInfo,
|
||||||
generateSshKey,
|
generateSshKey,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const { execFileSync } = require("node:child_process");
|
|||||||
/** Lower case, a letter first, ≤ 29 so `ah-<name>` fits Linux's 32. */
|
/** Lower case, a letter first, ≤ 29 so `ah-<name>` fits Linux's 32. */
|
||||||
const LINUX_NAME_RE = /^[a-z][a-z0-9_-]{0,28}$/;
|
const LINUX_NAME_RE = /^[a-z][a-z0-9_-]{0,28}$/;
|
||||||
const PROVISION = "/opt/agenthub/sbin/agenthub-provision";
|
const PROVISION = "/opt/agenthub/sbin/agenthub-provision";
|
||||||
const ACTIONS = new Set(["create", "disable", "enable"]);
|
const ACTIONS = new Set(["create", "disable", "enable", "delete"]);
|
||||||
|
|
||||||
const confPath = () => process.env.AGENTHUB_SERVER_CONF || "/etc/agenthub/server.conf";
|
const confPath = () => process.env.AGENTHUB_SERVER_CONF || "/etc/agenthub/server.conf";
|
||||||
|
|
||||||
@@ -121,7 +121,8 @@ function __setRunImpl(fn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create / disable / enable the Linux account of `username`. A no-op outside
|
* Create / disable / enable / delete (account AND home) the Linux account of
|
||||||
|
* `username`. A no-op outside
|
||||||
* S3 (returns null). Throws EPROVISION with the helper's own words on failure.
|
* S3 (returns null). Throws EPROVISION with the helper's own words on failure.
|
||||||
*/
|
*/
|
||||||
function provision(action, username) {
|
function provision(action, username) {
|
||||||
@@ -135,7 +136,12 @@ function provision(action, username) {
|
|||||||
.trim()
|
.trim()
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.pop();
|
.pop();
|
||||||
throw Object.assign(new Error(`could not ${action} the Linux account: ${why}`), {
|
// An installed helper older than this build (root's copy is refreshed
|
||||||
|
// only by install-system) does not know the action yet.
|
||||||
|
const hint = /unknown action/.test(why)
|
||||||
|
? " — the server helper is older than this AgentHub: run `sudo agenthub server install-system` once"
|
||||||
|
: "";
|
||||||
|
throw Object.assign(new Error(`could not ${action} the Linux account: ${why}${hint}`), {
|
||||||
code: "EPROVISION",
|
code: "EPROVISION",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,6 +154,11 @@ const PATH_RULES = [
|
|||||||
[/^\/workflows\/session\/([^/]+)/i, (req, m) => canSeeSession(req, m[1])],
|
[/^\/workflows\/session\/([^/]+)/i, (req, m) => canSeeSession(req, m[1])],
|
||||||
[/^\/workflows\/runs\/([^/]+)/i, (req, m) => canSeeRowSession(req, "workflows", m[1], "run_id")],
|
[/^\/workflows\/runs\/([^/]+)/i, (req, m) => canSeeRowSession(req, "workflows", m[1], "run_id")],
|
||||||
[/^\/alerts\/([^/]+)\/ack/i, (req, m) => canSeeRowSession(req, "alert_events", m[1])],
|
[/^\/alerts\/([^/]+)\/ack/i, (req, m) => canSeeRowSession(req, "alert_events", m[1])],
|
||||||
|
// `/artifacts/<id>…`: the owner's, or a shared one (server/lib/artifacts.js).
|
||||||
|
[
|
||||||
|
/^\/artifacts\/(\d+)(?:\/|$)/i,
|
||||||
|
(req, m) => require("./artifacts").canSeeId(req, m[1]),
|
||||||
|
],
|
||||||
[
|
[
|
||||||
/^\/snapshots\/([^/]+)$/i,
|
/^\/snapshots\/([^/]+)$/i,
|
||||||
(req, m) => req.method !== "DELETE" || canSeeRowSession(req, "session_snapshots", m[1]),
|
(req, m) => req.method !== "DELETE" || canSeeRowSession(req, "session_snapshots", m[1]),
|
||||||
@@ -296,6 +301,9 @@ function broadcastAudience(type, data) {
|
|||||||
case "lane_hook_result":
|
case "lane_hook_result":
|
||||||
case "lane_runtime":
|
case "lane_runtime":
|
||||||
return { owners: laneOwners(d.laneId) };
|
return { owners: laneOwners(d.laneId) };
|
||||||
|
// A shared artifact is everyone's news; a private one, its owner's.
|
||||||
|
case "artifact_changed":
|
||||||
|
return d.shared ? null : { owners: new Set(d.ownerId != null ? [d.ownerId] : []) };
|
||||||
case "update_status":
|
case "update_status":
|
||||||
// No payload: each client refetches its own `GET /api/run/interrupted`.
|
// No payload: each client refetches its own `GET /api/run/interrupted`.
|
||||||
case "runs_interrupted":
|
case "runs_interrupted":
|
||||||
|
|||||||
@@ -386,6 +386,22 @@ function serverPid() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the tmux server that holds (or held) session `name` started, in epoch
|
||||||
|
* ms — the host's, or the member's under S3 — or null when there is no such
|
||||||
|
* server or it cannot be asked. A pane gone while its server ran on since
|
||||||
|
* before the pane began exited by itself; one whose server is gone or newer
|
||||||
|
* died with it (a reboot, a killed tmux).
|
||||||
|
*/
|
||||||
|
function serverStartedAt(name) {
|
||||||
|
try {
|
||||||
|
const out = String(onServerOf(name, ["display-message", "-p", "#{start_time}"])).trim();
|
||||||
|
return /^\d+$/.test(out) ? Number(out) * 1000 : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** `-e NAME=value` pairs for tmux, in a stable order. */
|
/** `-e NAME=value` pairs for tmux, in a stable order. */
|
||||||
function envFlags(env) {
|
function envFlags(env) {
|
||||||
const flags = [];
|
const flags = [];
|
||||||
@@ -703,6 +719,7 @@ module.exports = {
|
|||||||
capturePane,
|
capturePane,
|
||||||
sendKey,
|
sendKey,
|
||||||
killSession,
|
killSession,
|
||||||
|
serverStartedAt,
|
||||||
listSessions,
|
listSessions,
|
||||||
liveSessions,
|
liveSessions,
|
||||||
isTmuxAvailable,
|
isTmuxAvailable,
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
/**
|
||||||
|
* @file artifacts.js (routes)
|
||||||
|
* @description `/api/artifacts` — the Artifacts hub (server/lib/artifacts.js):
|
||||||
|
* list, add (a claude.ai page, a pinned workspace file, a skill, a plugin),
|
||||||
|
* rename/share/unshare, delete, read a snapshot file (preview/download, under
|
||||||
|
* a `sandbox` CSP with `nosniff`, so nothing shared runs on the dashboard's
|
||||||
|
* origin), and install a shared skill or plugin into the caller's own
|
||||||
|
* account. Every write is same-origin. A path in a request only ever names a
|
||||||
|
* file inside a workspace (pin, under the read path's guards) or one of a
|
||||||
|
* snapshot's own recorded files.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require("express");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const artifacts = require("../lib/artifacts");
|
||||||
|
const { sameOriginGuard } = require("./run");
|
||||||
|
const { broadcast } = require("../websocket");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const STATUS = {
|
||||||
|
ENOTFOUND: 404,
|
||||||
|
ENOLANE: 404,
|
||||||
|
ENOFILE: 404,
|
||||||
|
EFORBIDDEN: 403,
|
||||||
|
ESECRET: 403,
|
||||||
|
EFOREIGN: 403,
|
||||||
|
EINSTALLSOFF: 403,
|
||||||
|
EEXISTS: 409,
|
||||||
|
ENOACCOUNT: 409,
|
||||||
|
EUPGRADE: 409,
|
||||||
|
ENOTSHARED: 409,
|
||||||
|
EPLUGIN: 502,
|
||||||
|
ETOOBIG: 413,
|
||||||
|
};
|
||||||
|
|
||||||
|
function sendError(res, err) {
|
||||||
|
if (err && typeof err.code === "string" && /^E[A-Z]+$/.test(err.code)) {
|
||||||
|
return res
|
||||||
|
.status(STATUS[err.code] || 400)
|
||||||
|
.json({ error: { code: err.code, message: err.message } });
|
||||||
|
}
|
||||||
|
console.warn("[artifacts]", err);
|
||||||
|
return res.status(500).json({ error: { code: "EINTERNAL", message: String(err?.message || err) } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tell clients a row changed: everyone when shared, else its owner (+ staff). */
|
||||||
|
function changed(row) {
|
||||||
|
if (!row) return;
|
||||||
|
broadcast("artifact_changed", { id: row.id, ownerId: row.ownerId ?? row.owner_id ?? null, shared: !!(row.shared === true || row.shared === 1) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrap = (fn) => async (req, res) => {
|
||||||
|
try {
|
||||||
|
await fn(req, res);
|
||||||
|
} catch (err) {
|
||||||
|
sendError(res, err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
wrap((req, res) => {
|
||||||
|
const q = req.query;
|
||||||
|
res.json({
|
||||||
|
items: artifacts.list(req, {
|
||||||
|
scope: q.scope,
|
||||||
|
kind: q.kind || undefined,
|
||||||
|
q: q.q,
|
||||||
|
laneId: q.laneId,
|
||||||
|
sessionId: q.sessionId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/settings",
|
||||||
|
wrap((req, res) => {
|
||||||
|
const server = require("../lib/auth").isServerMode();
|
||||||
|
res.json({
|
||||||
|
serverMode: server,
|
||||||
|
canShare: server,
|
||||||
|
installsEnabled: artifacts.installsEnabled(),
|
||||||
|
canManage: !req.user || req.user.role === "admin",
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put(
|
||||||
|
"/settings",
|
||||||
|
sameOriginGuard,
|
||||||
|
wrap((req, res) => {
|
||||||
|
if (req.user && req.user.role !== "admin") {
|
||||||
|
return res.status(403).json({ error: { code: "EADMIN", message: "admin only" } });
|
||||||
|
}
|
||||||
|
const on = req.body?.installsEnabled;
|
||||||
|
if (typeof on !== "boolean") {
|
||||||
|
return res.status(400).json({ error: { code: "EBADFIELD", message: "installsEnabled: boolean" } });
|
||||||
|
}
|
||||||
|
artifacts.setInstallsEnabled(on);
|
||||||
|
if (req.user) {
|
||||||
|
require("../lib/audit").recordAccountChange(
|
||||||
|
req.user.id,
|
||||||
|
null,
|
||||||
|
"PUT",
|
||||||
|
`artifact hub installs ${on ? "on" : "off"}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
res.json({ installsEnabled: artifacts.installsEnabled() });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/candidates",
|
||||||
|
wrap((req, res) => res.json({ items: artifacts.fileCandidates(req.query.laneId) }))
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/skills",
|
||||||
|
wrap((req, res) => res.json({ items: artifacts.availableSkills(req, req.query.laneId) }))
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/page",
|
||||||
|
sameOriginGuard,
|
||||||
|
wrap((req, res) => {
|
||||||
|
const item = artifacts.addPage(req, req.body || {});
|
||||||
|
changed(item);
|
||||||
|
res.status(201).json({ item });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/file",
|
||||||
|
sameOriginGuard,
|
||||||
|
wrap(async (req, res) => {
|
||||||
|
const item = await artifacts.pinFile(req, req.body || {});
|
||||||
|
changed(item);
|
||||||
|
res.status(201).json({ item });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/skill",
|
||||||
|
sameOriginGuard,
|
||||||
|
wrap((req, res) => {
|
||||||
|
const item = artifacts.addSkill(req, req.body || {});
|
||||||
|
changed(item);
|
||||||
|
res.status(201).json({ item });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/plugin",
|
||||||
|
sameOriginGuard,
|
||||||
|
wrap((req, res) => {
|
||||||
|
const item = artifacts.addPlugin(req, req.body || {});
|
||||||
|
changed(item);
|
||||||
|
res.status(201).json({ item });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/:id",
|
||||||
|
wrap((req, res) => res.json({ item: artifacts.view(artifacts.load(req, req.params.id), req) }))
|
||||||
|
);
|
||||||
|
|
||||||
|
router.patch(
|
||||||
|
"/:id",
|
||||||
|
sameOriginGuard,
|
||||||
|
wrap((req, res) => {
|
||||||
|
const item = artifacts.update(req, req.params.id, req.body || {});
|
||||||
|
changed(item);
|
||||||
|
res.json({ item });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
"/:id",
|
||||||
|
sameOriginGuard,
|
||||||
|
wrap((req, res) => {
|
||||||
|
const row = artifacts.remove(req, req.params.id);
|
||||||
|
changed(row);
|
||||||
|
res.json({ ok: true });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// One recorded file of a snapshot. Always sandboxed; `?download=1` makes it
|
||||||
|
// an attachment. The type is only ever one of a fixed list, else a download.
|
||||||
|
const TYPES = {
|
||||||
|
html: "text/html; charset=utf-8",
|
||||||
|
htm: "text/html; charset=utf-8",
|
||||||
|
md: "text/plain; charset=utf-8",
|
||||||
|
markdown: "text/plain; charset=utf-8",
|
||||||
|
txt: "text/plain; charset=utf-8",
|
||||||
|
log: "text/plain; charset=utf-8",
|
||||||
|
csv: "text/plain; charset=utf-8",
|
||||||
|
tsv: "text/plain; charset=utf-8",
|
||||||
|
json: "text/plain; charset=utf-8",
|
||||||
|
yaml: "text/plain; charset=utf-8",
|
||||||
|
yml: "text/plain; charset=utf-8",
|
||||||
|
sh: "text/plain; charset=utf-8",
|
||||||
|
py: "text/plain; charset=utf-8",
|
||||||
|
js: "text/plain; charset=utf-8",
|
||||||
|
ts: "text/plain; charset=utf-8",
|
||||||
|
png: "image/png",
|
||||||
|
jpg: "image/jpeg",
|
||||||
|
jpeg: "image/jpeg",
|
||||||
|
gif: "image/gif",
|
||||||
|
webp: "image/webp",
|
||||||
|
svg: "image/svg+xml",
|
||||||
|
pdf: "application/pdf",
|
||||||
|
mp4: "video/mp4",
|
||||||
|
webm: "video/webm",
|
||||||
|
mp3: "audio/mpeg",
|
||||||
|
wav: "audio/wav",
|
||||||
|
};
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/:id/raw",
|
||||||
|
wrap((req, res) => {
|
||||||
|
const { abs, name } = artifacts.snapshotFile(req, req.params.id, req.query.path);
|
||||||
|
const ext = name.split(".").pop().toLowerCase();
|
||||||
|
const type = TYPES[ext];
|
||||||
|
const download = req.query.download === "1" || !type;
|
||||||
|
res.set({
|
||||||
|
"Content-Type": download ? "application/octet-stream" : type,
|
||||||
|
"Content-Security-Policy": "default-src 'none'; img-src data:; style-src 'unsafe-inline'; media-src 'self'; sandbox",
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
"Cache-Control": "private, max-age=60",
|
||||||
|
...(download
|
||||||
|
? { "Content-Disposition": `attachment; filename*=UTF-8''${encodeURIComponent(name)}` }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
fs.createReadStream(abs)
|
||||||
|
.on("error", () => res.destroy())
|
||||||
|
.pipe(res);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/:id/install",
|
||||||
|
sameOriginGuard,
|
||||||
|
wrap(async (req, res) => {
|
||||||
|
const result = await artifacts.install(req, req.params.id, {
|
||||||
|
replace: req.body?.replace === true,
|
||||||
|
});
|
||||||
|
res.json(result);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -26,6 +26,7 @@ const STATUS = {
|
|||||||
EHOSTIDENTITY: 403,
|
EHOSTIDENTITY: 403,
|
||||||
EBADAUTHOR: 400,
|
EBADAUTHOR: 400,
|
||||||
ESTOREUNREADABLE: 409,
|
ESTOREUNREADABLE: 409,
|
||||||
|
EBADKEYTYPE: 400,
|
||||||
};
|
};
|
||||||
|
|
||||||
function fail(res, e) {
|
function fail(res, e) {
|
||||||
@@ -66,8 +67,9 @@ router.get("/", async (req, res) => {
|
|||||||
router.post("/ssh-key", sameOriginGuard, async (req, res) => {
|
router.post("/ssh-key", sameOriginGuard, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const account = accountOf(req, { write: true });
|
const account = accountOf(req, { write: true });
|
||||||
identity.generateSshKey(account, {
|
await identity.generateSshKey(account, {
|
||||||
replace: req.body?.replace === true,
|
replace: req.body?.replace === true,
|
||||||
|
type: req.body?.type === "rsa" ? "rsa" : "ed25519",
|
||||||
comment: req.user?.username ? `${req.user.username}@agenthub` : "agenthub",
|
comment: req.user?.username ? `${req.user.username}@agenthub` : "agenthub",
|
||||||
});
|
});
|
||||||
res.status(201).json(await view(account));
|
res.status(201).json(await view(account));
|
||||||
|
|||||||
@@ -1230,6 +1230,17 @@ const processEventTx = db.transaction((hookType, data, tool = "claude") => {
|
|||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
broadcast("new_event", event);
|
broadcast("new_event", event);
|
||||||
|
// A claude.ai page the session published goes into its owner's Artifacts.
|
||||||
|
if (hookType === "PostToolUse" && toolName === "Artifact" && tool !== "codex") {
|
||||||
|
try {
|
||||||
|
const row = require("../lib/artifacts").captureFromHook(sessionId, data);
|
||||||
|
if (row) {
|
||||||
|
broadcast("artifact_changed", { id: row.id, ownerId: row.owner_id, shared: false });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[artifacts] capture failed:", err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
return event;
|
return event;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+106
-21
@@ -735,8 +735,14 @@ router.post("/browse/mkdir", sameOriginGuard, (req, res) => {
|
|||||||
* login, 409 `ESSHAUTH` means the key was refused — 409 like the git bar's
|
* login, 409 `ESSHAUTH` means the key was refused — 409 like the git bar's
|
||||||
* `EGITAUTH`, never 401, which the client reads as "signed out".
|
* `EGITAUTH`, never 401, which the client reads as "signed out".
|
||||||
*/
|
*/
|
||||||
router.post("/clone", sameOriginGuard, async (req, res) => {
|
/**
|
||||||
const body = req.body || {};
|
* The clone itself, shared by Add workspace → Clone and a workspace's Clone
|
||||||
|
* extra folder: every check above, then `git clone` as the folder's account.
|
||||||
|
* Answers the new folder's path, or null once it has sent the refusal.
|
||||||
|
* `before(target, ownerId)` runs after the checks and BEFORE the clone, so a
|
||||||
|
* refusal there never leaves minutes of cloning behind.
|
||||||
|
*/
|
||||||
|
async function cloneInto(req, res, body, before) {
|
||||||
const identity = require("../lib/git-identity");
|
const identity = require("../lib/git-identity");
|
||||||
let url;
|
let url;
|
||||||
try {
|
try {
|
||||||
@@ -744,7 +750,8 @@ router.post("/clone", sameOriginGuard, async (req, res) => {
|
|||||||
typeof body.url === "string" ? body.url.trim() : body.url
|
typeof body.url === "string" ? body.url.trim() : body.url
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
const name =
|
const name =
|
||||||
typeof body.name === "string" && body.name.trim()
|
typeof body.name === "string" && body.name.trim()
|
||||||
@@ -757,45 +764,57 @@ router.post("/clone", sameOriginGuard, async (req, res) => {
|
|||||||
/[/\\\0]/.test(name) ||
|
/[/\\\0]/.test(name) ||
|
||||||
Buffer.byteLength(name) > 255
|
Buffer.byteLength(name) > 255
|
||||||
) {
|
) {
|
||||||
return res
|
res
|
||||||
.status(400)
|
.status(400)
|
||||||
.json({ error: { code: "EBADNAME", message: "a folder name without '/' or '\\'" } });
|
.json({ error: { code: "EBADNAME", message: "a folder name without '/' or '\\'" } });
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
if (typeof body.parent !== "string" || !path.isAbsolute(body.parent)) {
|
if (typeof body.parent !== "string" || !path.isAbsolute(body.parent)) {
|
||||||
return res.status(400).json({ error: { code: "EBADPATH", message: "parent: absolute path" } });
|
res.status(400).json({ error: { code: "EBADPATH", message: "parent: absolute path" } });
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
if (body.tool !== undefined && !aiTools.has(body.tool)) {
|
if (body.tool !== undefined && !aiTools.has(body.tool)) {
|
||||||
return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } });
|
res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } });
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
const once = body.credentials;
|
const once = body.credentials;
|
||||||
if (once != null && (typeof once !== "object" || Array.isArray(once))) {
|
if (once != null && (typeof once !== "object" || Array.isArray(once))) {
|
||||||
return res.status(400).json({ error: { code: "EBADCRED", message: "credentials: object" } });
|
res.status(400).json({ error: { code: "EBADCRED", message: "credentials: object" } });
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
const own = scope.ownFolder(req);
|
const own = scope.ownFolder(req);
|
||||||
const limited = Boolean(req.user) && req.user.role !== "admin";
|
const limited = Boolean(req.user) && req.user.role !== "admin";
|
||||||
if (limited && !own) return refuseFolder(res);
|
if (limited && !own) {
|
||||||
|
refuseFolder(res);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const dir = own ? lanesLib.canonicalCwd(path.resolve(body.parent)) : path.resolve(body.parent);
|
const dir = own ? lanesLib.canonicalCwd(path.resolve(body.parent)) : path.resolve(body.parent);
|
||||||
if (own && !scope.folderAllowed(req, dir, { allowHome: true })) return refuseFolder(res);
|
if (own && !scope.folderAllowed(req, dir, { allowHome: true })) {
|
||||||
|
refuseFolder(res);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (!fs.statSync(dir).isDirectory()) throw new Error("not a directory");
|
if (!fs.statSync(dir).isDirectory()) throw new Error("not a directory");
|
||||||
} catch {
|
} catch {
|
||||||
return res
|
res.status(400).json({ error: { code: "ENOTFOUND", message: "parent folder does not exist" } });
|
||||||
.status(400)
|
return null;
|
||||||
.json({ error: { code: "ENOTFOUND", message: "parent folder does not exist" } });
|
|
||||||
}
|
}
|
||||||
const target = path.join(dir, name);
|
const target = path.join(dir, name);
|
||||||
if (fs.existsSync(target) || isSymlink(target)) {
|
if (fs.existsSync(target) || isSymlink(target)) {
|
||||||
return res.status(409).json({ error: { code: "EEXISTS", message: "that name is taken" } });
|
res.status(409).json({ error: { code: "EEXISTS", message: "that name is taken" } });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!scope.folderAllowed(req, target)) {
|
||||||
|
refuseFolder(res);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (scope.folderTakenByOther(req, target)) {
|
||||||
|
refuseOwned(res);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
if (!scope.folderAllowed(req, target)) return refuseFolder(res);
|
|
||||||
if (scope.folderTakenByOther(req, target)) return refuseOwned(res);
|
|
||||||
const ownerId = scope.ownerFor(req);
|
const ownerId = scope.ownerFor(req);
|
||||||
try {
|
try {
|
||||||
// Before the clone: a workspace this user may not own there, or a tool
|
await before(target, ownerId);
|
||||||
// that is disabled, is refused up front — not after minutes of cloning,
|
|
||||||
// leaving a clone with no workspace on it.
|
|
||||||
lanesLib.validateTool(body.tool || "claude");
|
|
||||||
require("../lib/run-as").assertOwnerFits(target, ownerId);
|
|
||||||
await identity.cloneRepo({
|
await identity.cloneRepo({
|
||||||
url,
|
url,
|
||||||
parent: dir,
|
parent: dir,
|
||||||
@@ -810,7 +829,7 @@ router.post("/clone", sameOriginGuard, async (req, res) => {
|
|||||||
: ["ECREDENTIALS", "ESSHAUTH", "ENOREPO", "EOWNERFOLDER", "EMEMBERGONE"].includes(err.code)
|
: ["ECREDENTIALS", "ESSHAUTH", "ENOREPO", "EOWNERFOLDER", "EMEMBERGONE"].includes(err.code)
|
||||||
? 409
|
? 409
|
||||||
: 500;
|
: 500;
|
||||||
return res.status(status).json({
|
res.status(status).json({
|
||||||
error: {
|
error: {
|
||||||
code: err.code || "ECLONE",
|
code: err.code || "ECLONE",
|
||||||
message: err.message,
|
message: err.message,
|
||||||
@@ -819,7 +838,22 @@ router.post("/clone", sameOriginGuard, async (req, res) => {
|
|||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
return { target, name, ownerId };
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post("/clone", sameOriginGuard, async (req, res) => {
|
||||||
|
const body = req.body || {};
|
||||||
|
// Before the clone: a workspace this user may not own there, or a tool
|
||||||
|
// that is disabled, is refused up front — not after minutes of cloning,
|
||||||
|
// leaving a clone with no workspace on it.
|
||||||
|
const done = await cloneInto(req, res, body, (target, ownerId) => {
|
||||||
|
lanesLib.validateTool(body.tool || "claude");
|
||||||
|
require("../lib/run-as").assertOwnerFits(target, ownerId);
|
||||||
|
});
|
||||||
|
if (!done) return;
|
||||||
|
const { target, name, ownerId } = done;
|
||||||
try {
|
try {
|
||||||
const lane = lanesLib.createLane({
|
const lane = lanesLib.createLane({
|
||||||
cwd: lanesLib.canonicalCwd(target),
|
cwd: lanesLib.canonicalCwd(target),
|
||||||
@@ -838,6 +872,57 @@ router.post("/clone", sameOriginGuard, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clone a related repo as one of a workspace's extra folders: the same clone
|
||||||
|
* as above (URL allowlist, the folder account's own identity, `ECREDENTIALS`
|
||||||
|
* / `ESSHAUTH`), always BESIDE the workspace folder (its parent — no path
|
||||||
|
* from the request), then `lane_sources`' own folder checks. The clone is the
|
||||||
|
* user's from then on: removing the extra folder never deletes it.
|
||||||
|
*/
|
||||||
|
router.post("/:id/sources/clone", sameOriginGuard, async (req, res) => {
|
||||||
|
const lane = lanesLib.getLane(Number.parseInt(req.params.id, 10));
|
||||||
|
if (!lane) {
|
||||||
|
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||||||
|
}
|
||||||
|
const sources = require("../lib/lane-sources");
|
||||||
|
if (!sources.supports(lane.tool)) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: { code: "EUNSUPPORTED", message: "this tool takes no extra folders" } });
|
||||||
|
}
|
||||||
|
const body = req.body || {};
|
||||||
|
const done = await cloneInto(
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
{
|
||||||
|
url: body.url,
|
||||||
|
name: body.name,
|
||||||
|
credentials: body.credentials,
|
||||||
|
parent: path.dirname(lane.cwd),
|
||||||
|
},
|
||||||
|
() => {}
|
||||||
|
);
|
||||||
|
if (!done) return;
|
||||||
|
try {
|
||||||
|
const allowed = (p) => scope.folderAllowed(req, lanesLib.canonicalCwd(path.resolve(p)));
|
||||||
|
sources.addFolder(lane.id, done.target, { allowed });
|
||||||
|
broadcastLane(lane.id);
|
||||||
|
return res.status(201).json({
|
||||||
|
path: done.target,
|
||||||
|
instructions: lane.instructions || "",
|
||||||
|
supported: true,
|
||||||
|
maxFileBytes: sources.MAX_FILE_BYTES,
|
||||||
|
maxInstructions: sources.MAX_INSTRUCTIONS,
|
||||||
|
sources: sources.listSources(lane.id),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// The clone stays where it is; it can still be added by its path.
|
||||||
|
return res
|
||||||
|
.status(err.code === "EFOLDER" ? 403 : 400)
|
||||||
|
.json({ error: { code: err.code || "ESOURCE", message: err.message }, path: done.target });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function isSymlink(p) {
|
function isSymlink(p) {
|
||||||
try {
|
try {
|
||||||
return fs.lstatSync(p).isSymbolicLink();
|
return fs.lstatSync(p).isSymbolicLink();
|
||||||
|
|||||||
@@ -504,7 +504,8 @@ router.get("/:id/outline", (req, res) => {
|
|||||||
|
|
||||||
// An image the session's own Read tool returned (`?path=`, exact), for the Chat
|
// An image the session's own Read tool returned (`?path=`, exact), for the Chat
|
||||||
// pane's Files read preview when it lies outside the workspace (a screenshot
|
// pane's Files read preview when it lies outside the workspace (a screenshot
|
||||||
// under ~/.cache, /tmp). The path is authorized only by that event, so: raster
|
// under ~/.cache, /tmp) — or one its Bash or a reply named (a chart a script
|
||||||
|
// drew, `sessionNamedFile`). The path is authorized only by that event, so: raster
|
||||||
// types only, under a sandbox CSP with nosniff; never for a pushed session
|
// types only, under a sandbox CSP with nosniff; never for a pushed session
|
||||||
// (another machine's paths). In server mode only with S3, where a member's hook
|
// (another machine's paths). In server mode only with S3, where a member's hook
|
||||||
// reports sessions in its own home only (hook-auth.js): a member's session gets
|
// reports sessions in its own home only (hook-auth.js): a member's session gets
|
||||||
@@ -524,6 +525,56 @@ const sessionReadFile = db.prepare(
|
|||||||
AND json_extract(data, '$.tool_input.file_path') = ?
|
AND json_extract(data, '$.tool_input.file_path') = ?
|
||||||
LIMIT 1`
|
LIMIT 1`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The other way a session can point at a file (issue #76): a report its own
|
||||||
|
// script generated (`python audit.py -o ~/Documents/r.md`), which no
|
||||||
|
// Read/Write names, but its Bash command or output, or a reply's text, does.
|
||||||
|
const sessionNamedText = db.prepare(
|
||||||
|
`SELECT t FROM (
|
||||||
|
SELECT coalesce(json_extract(data, '$.tool_input.command'), '') || char(10) ||
|
||||||
|
coalesce(json_extract(data, '$.tool_response.stdout'), '') || char(10) ||
|
||||||
|
coalesce(json_extract(data, '$.last_assistant_message'), '') AS t
|
||||||
|
FROM events
|
||||||
|
WHERE session_id = ?
|
||||||
|
AND ((event_type = 'PostToolUse' AND tool_name = 'Bash')
|
||||||
|
OR event_type IN ('Stop', 'SubagentStop')))
|
||||||
|
WHERE instr(t, ?) > 0
|
||||||
|
LIMIT 50`
|
||||||
|
);
|
||||||
|
const PATH_CHAR = /[\w./\\~-]/;
|
||||||
|
/** Whether `text` names `file` as a whole path — `/a/b` is not named by
|
||||||
|
* `/a/b.md` or `/x/a/b`; a `file://` link and a sentence's full stop are. */
|
||||||
|
function namesPath(text, file) {
|
||||||
|
for (let i = text.indexOf(file); i !== -1; i = text.indexOf(file, i + 1)) {
|
||||||
|
const before = text[i - 1];
|
||||||
|
const after = text[i + file.length];
|
||||||
|
const okBefore =
|
||||||
|
before === undefined ||
|
||||||
|
!PATH_CHAR.test(before) ||
|
||||||
|
text.slice(Math.max(0, i - 7), i).toLowerCase() === "file://";
|
||||||
|
const okAfter =
|
||||||
|
after === undefined ||
|
||||||
|
(after === "." ? !/\w/.test(text[i + file.length + 1] || "") : !PATH_CHAR.test(after));
|
||||||
|
if (okBefore && okAfter) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
/** Whether the session's own Bash calls or replies named `file`. */
|
||||||
|
function sessionNamedFile(sessionId, file) {
|
||||||
|
return sessionNamedText.all(sessionId, file).some((r) => namesPath(r.t, file));
|
||||||
|
}
|
||||||
|
/** A file only NAMED (not touched) is served only when the session's account
|
||||||
|
* could read it itself: access(2) checks without the unit's
|
||||||
|
* CAP_DAC_READ_SEARCH, so a reply quoting `/etc/shadow` serves nothing. */
|
||||||
|
async function readableByAccount(file, member) {
|
||||||
|
if (member) return true; // inside their home and owned by them, checked below
|
||||||
|
try {
|
||||||
|
await fs.promises.access(file, fs.constants.R_OK);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
router.get("/:id/image", async (req, res) => {
|
router.get("/:id/image", async (req, res) => {
|
||||||
const fail = (status, code, message) => res.status(status).json({ error: { code, message } });
|
const fail = (status, code, message) => res.status(status).json({ error: { code, message } });
|
||||||
const session = stmts.getSession.get(req.params.id);
|
const session = stmts.getSession.get(req.params.id);
|
||||||
@@ -540,7 +591,10 @@ router.get("/:id/image", async (req, res) => {
|
|||||||
if (member && !require("../lib/hook-auth").inside(member.home, file)) {
|
if (member && !require("../lib/hook-auth").inside(member.home, file)) {
|
||||||
return fail(404, "ENOTFOUND", "the session never read that image");
|
return fail(404, "ENOTFOUND", "the session never read that image");
|
||||||
}
|
}
|
||||||
if (!sessionReadFile.get(session.id, file)) {
|
if (
|
||||||
|
!sessionReadFile.get(session.id, file) &&
|
||||||
|
!(sessionNamedFile(session.id, file) && (await readableByAccount(file, member)))
|
||||||
|
) {
|
||||||
return fail(404, "ENOTFOUND", "the session never read that image");
|
return fail(404, "ENOTFOUND", "the session never read that image");
|
||||||
}
|
}
|
||||||
let fh;
|
let fh;
|
||||||
@@ -571,8 +625,10 @@ router.get("/:id/image", async (req, res) => {
|
|||||||
|
|
||||||
// A text file the session's own Read, Write, Edit, MultiEdit or NotebookEdit
|
// A text file the session's own Read, Write, Edit, MultiEdit or NotebookEdit
|
||||||
// named (`?path=`, exact), for the Chat pane's Outputs/Files preview when it
|
// named (`?path=`, exact), for the Chat pane's Outputs/Files preview when it
|
||||||
// lies outside the workspace (a report written to ~/Documents, issue #76).
|
// lies outside the workspace (a report written to ~/Documents, issue #76) —
|
||||||
// The same authorization as `/image` — that event, never a pushed session,
|
// or one its Bash command, its output or a reply named as a whole path (a
|
||||||
|
// report a script generated, `sessionNamedFile`), when the session's account
|
||||||
|
// could read it itself. The same authorization as `/image` — that event, never a pushed session,
|
||||||
// server mode only with S3 and a member's session only inside their own home
|
// server mode only with S3 and a member's session only inside their own home
|
||||||
// on a file they own — plus: never a credential by name
|
// on a file they own — plus: never a credential by name
|
||||||
// (`isSecretConfigPath`, a `settings.local.json`), never a binary or a
|
// (`isSecretConfigPath`, a `settings.local.json`), never a binary or a
|
||||||
@@ -605,7 +661,10 @@ router.get("/:id/file", async (req, res) => {
|
|||||||
if (member && !require("../lib/hook-auth").inside(member.home, file)) {
|
if (member && !require("../lib/hook-auth").inside(member.home, file)) {
|
||||||
return fail(404, "ENOTFOUND", "the session never touched that file");
|
return fail(404, "ENOTFOUND", "the session never touched that file");
|
||||||
}
|
}
|
||||||
if (!sessionTouchedFile.get(session.id, file, file)) {
|
if (
|
||||||
|
!sessionTouchedFile.get(session.id, file, file) &&
|
||||||
|
!(sessionNamedFile(session.id, file) && (await readableByAccount(file, member)))
|
||||||
|
) {
|
||||||
return fail(404, "ENOTFOUND", "the session never touched that file");
|
return fail(404, "ENOTFOUND", "the session never touched that file");
|
||||||
}
|
}
|
||||||
let fh;
|
let fh;
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ const STATUS = {
|
|||||||
EEXISTS: 409,
|
EEXISTS: 409,
|
||||||
ESELF: 409,
|
ESELF: 409,
|
||||||
EOWNERFOLDER: 409, // S3: a workspace in one member's home, assigned to another
|
EOWNERFOLDER: 409, // S3: a workspace in one member's home, assigned to another
|
||||||
|
ELASTADMIN: 409,
|
||||||
ETOOMANY: 429,
|
ETOOMANY: 429,
|
||||||
// S3: the Linux account could not be created/locked (the helper said why).
|
// S3: the Linux account could not be created/locked (the helper said why).
|
||||||
EPROVISION: 502,
|
EPROVISION: 502,
|
||||||
@@ -248,6 +249,27 @@ router.patch(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a user for good (auth.deleteUser): their logins, panes, workspaces'
|
||||||
|
* records and — under S3 — their Linux account and whole home. Never
|
||||||
|
* yourself (ESELF) or the last admin (ELASTADMIN); the admin's password
|
||||||
|
* again, like every account change, and written to the audit log.
|
||||||
|
*/
|
||||||
|
router.delete(
|
||||||
|
"/:id",
|
||||||
|
accountChange(async (req) => {
|
||||||
|
const u = target(req);
|
||||||
|
if (u.id === req.user.id) {
|
||||||
|
throw Object.assign(new Error("you cannot delete yourself"), { code: "ESELF" });
|
||||||
|
}
|
||||||
|
const gone = auth.deleteUser(u.username);
|
||||||
|
return {
|
||||||
|
user: auth.publicUser(u),
|
||||||
|
action: `delete ${u.username} (${gone.workspaces} workspace(s), home removed)`,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
"/:id/password",
|
"/:id/password",
|
||||||
accountChange(async (req, body) => {
|
accountChange(async (req, body) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user