Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1dd2521183 | |||
| 21a042dc2b | |||
| 85edc88a6d | |||
| c229b4b79b | |||
| 9721941962 | |||
| b777e08371 | |||
| d0b4f8aaf6 | |||
| 916505fbac | |||
| 75a4693f27 | |||
| cd6a1d36ff | |||
| 2342f54179 | |||
| 246b554dab | |||
| 7ee79292fa | |||
| 600e8eec6a | |||
| 3ec1978176 | |||
| 12ea853170 | |||
| 7e41c81d53 | |||
| 2d6c751fb2 | |||
| e0b8d5641e | |||
| faa96d1964 | |||
| 68c3cfaf26 | |||
| 1ad2fec073 | |||
| 1294ce1faa | |||
| 5d62003fe5 | |||
| 12d53afaeb | |||
| 4263b3eb26 | |||
| d58f13e1ce |
@@ -287,6 +287,11 @@ function UploadChip({ job }: { job: UploadJob }) {
|
||||
{isArmed("stop") && !stopping && (
|
||||
<p className="text-[11px] text-status-danger">{t("uploads.stopConfirm")}</p>
|
||||
)}
|
||||
{job.waiting && !stopping && (
|
||||
<p className="text-[11px] text-status-warning" role="status" data-testid="upload-waiting">
|
||||
{t("uploads.waiting")}
|
||||
</p>
|
||||
)}
|
||||
<p className="truncate text-[11px] text-fg-muted" title={job.folder}>
|
||||
{job.folder}
|
||||
{job.name ? ` · ${job.name}` : ""}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* @file NeedsYouStrip.tsx
|
||||
* @description The Dashboard's pinned "Needs you" strip: every active session
|
||||
* blocked on the user (`awaiting_input_since`), each linking straight to its
|
||||
* of the viewer's OWN blocked on them (`awaiting_input_since`, `mine=1` — an
|
||||
* admin's lists no other user's), each linking straight to its
|
||||
* workspace pane (or replay), else its analysis page. Renders nothing while no
|
||||
* session is waiting. `compact` is the one-line form shown above Analytics.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -27,7 +28,7 @@ export function NeedsYouStrip({ compact = false }: { compact?: boolean }) {
|
||||
|
||||
const load = useCallback(() => {
|
||||
api.sessions
|
||||
.list({ awaiting: true, limit: 20 })
|
||||
.list({ awaiting: true, limit: 20, mine: true })
|
||||
.then((r) => {
|
||||
setSessions(r.sessions);
|
||||
setTotal(r.total);
|
||||
|
||||
@@ -0,0 +1,846 @@
|
||||
/**
|
||||
* @file ChatHub.tsx
|
||||
* @description Chat between users inside the pet's window (server mode,
|
||||
* `/api/chat`, state from `useChat.ts`). `ChatRail` is the narrow column
|
||||
* beside the window: the pet itself first (its AI chat, `PetChat.tsx`), then
|
||||
* the user's direct chats and groups, newest first, each with its unread
|
||||
* count, and **+** for a new chat. `ChatRoomView` is one room, the same size as
|
||||
* the pet's chat: messages (a group shows who wrote each), older pages on
|
||||
* demand, text with links, pasted / dropped / attached files (images shown
|
||||
* inline, anything else a download, all served only to the room's members),
|
||||
* and for a group its settings — the creator renames it and adds or removes
|
||||
* people; anyone may leave. `NewChat` picks people: one person opens the
|
||||
* direct chat with them, more (or a name) makes a group. Only a room's members
|
||||
* ever see it; an admin has no view of other people's chats.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowUp,
|
||||
FileText,
|
||||
LogOut,
|
||||
Paperclip,
|
||||
Plus,
|
||||
Settings2,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api, type ChatMessage, type ChatPerson, type ChatRoom } from "../../lib/api";
|
||||
import { formatBytes, formatTime } from "../../lib/format";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { UserAvatar } from "../users/UserAvatar";
|
||||
import { useAuth } from "../AuthGate";
|
||||
import type { ChatState } from "./useChat";
|
||||
|
||||
const MAX_FILES = 10;
|
||||
const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
|
||||
/** Shown name of a person (display name, else login). */
|
||||
const personName = (p: Pick<ChatPerson, "username" | "displayName"> | undefined) =>
|
||||
p ? p.displayName || p.username : "?";
|
||||
|
||||
/** Everyone the view may need a name for: the people list plus the viewer. */
|
||||
function useDirectory(chat: ChatState) {
|
||||
const { state } = useAuth();
|
||||
const self = state.mode === "server" ? state.user : null;
|
||||
return useMemo(() => {
|
||||
const m = new Map<number, ChatPerson>(chat.people.map((p) => [p.id, p]));
|
||||
if (self)
|
||||
m.set(self.id, {
|
||||
id: self.id,
|
||||
username: self.username,
|
||||
displayName: self.displayName ?? null,
|
||||
avatarAt: self.avatarAt ?? null,
|
||||
});
|
||||
return m;
|
||||
}, [chat.people, self]);
|
||||
}
|
||||
|
||||
/** The other person of a direct chat. */
|
||||
const otherOf = (room: ChatRoom, me: number | null) =>
|
||||
room.members.find((id) => id !== me) ?? room.members[0] ?? -1;
|
||||
|
||||
function roomTitle(room: ChatRoom, me: number | null, dir: Map<number, ChatPerson>) {
|
||||
if (room.kind === "dm") return personName(dir.get(otherOf(room, me)));
|
||||
if (room.name) return room.name;
|
||||
return room.members
|
||||
.filter((id) => id !== me)
|
||||
.map((id) => personName(dir.get(id)))
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function RoomIcon({
|
||||
room,
|
||||
me,
|
||||
dir,
|
||||
className,
|
||||
}: {
|
||||
room: ChatRoom;
|
||||
me: number | null;
|
||||
dir: Map<number, ChatPerson>;
|
||||
className: string;
|
||||
}) {
|
||||
if (room.kind === "dm") {
|
||||
const p = dir.get(otherOf(room, me));
|
||||
return (
|
||||
<UserAvatar
|
||||
name={personName(p)}
|
||||
userId={p?.id}
|
||||
avatarAt={p?.avatarAt}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className={`flex items-center justify-center rounded-full bg-surface-4 text-fg-secondary ${className}`}
|
||||
>
|
||||
<Users size={16} aria-hidden />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const Badge = ({ n }: { n: number }) =>
|
||||
n > 0 ? (
|
||||
<span className="absolute -right-0.5 -top-0.5 min-w-[1.1rem] rounded-full bg-status-danger px-1 text-center text-[10px] font-semibold leading-[1.1rem] text-white">
|
||||
{n > 99 ? "99+" : n}
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
/** The column beside the window: the pet, the rooms, and New chat. */
|
||||
export function ChatRail({
|
||||
chat,
|
||||
active,
|
||||
onSelect,
|
||||
petName,
|
||||
}: {
|
||||
chat: ChatState;
|
||||
/** `"pet"`, `"new"` or a room id. */
|
||||
active: "pet" | "new" | number;
|
||||
onSelect: (v: "pet" | "new" | number) => void;
|
||||
petName: string;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const dir = useDirectory(chat);
|
||||
const ring = (on: boolean) =>
|
||||
on ? "ring-2 ring-accent ring-offset-2 ring-offset-surface-2" : "opacity-80 hover:opacity-100";
|
||||
return (
|
||||
<nav
|
||||
className="pet-chat flex h-[30rem] max-h-[calc(100vh-6rem)] w-14 flex-col items-center gap-2 overflow-y-auto rounded-3xl border border-border-light py-3 shadow-2xl"
|
||||
aria-label={t("sagi.people.rail")}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect("pet")}
|
||||
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-surface-4 text-lg transition ${ring(active === "pet")}`}
|
||||
title={petName}
|
||||
aria-label={petName}
|
||||
aria-pressed={active === "pet"}
|
||||
>
|
||||
<span aria-hidden>🐾</span>
|
||||
</button>
|
||||
<span className="h-px w-8 shrink-0 bg-border" aria-hidden />
|
||||
{chat.rooms.map((r) => {
|
||||
const title = roomTitle(r, chat.me, dir);
|
||||
return (
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(r.id)}
|
||||
className={`relative shrink-0 rounded-full transition ${ring(active === r.id)}`}
|
||||
title={title}
|
||||
aria-label={r.unread ? t("sagi.people.unreadIn", { name: title, n: r.unread }) : title}
|
||||
aria-pressed={active === r.id}
|
||||
>
|
||||
<RoomIcon room={r} me={chat.me} dir={dir} className="h-9 w-9 text-[11px]" />
|
||||
<Badge n={r.unread} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect("new")}
|
||||
className={`mt-auto flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-dashed border-border text-fg-muted transition hover:text-fg-primary ${active === "new" ? "ring-2 ring-accent" : ""}`}
|
||||
title={t("sagi.people.new")}
|
||||
aria-label={t("sagi.people.new")}
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function Shell({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className="pet-chat flex h-[30rem] max-h-[calc(100vh-6rem)] w-80 flex-col overflow-hidden rounded-3xl border border-border-light shadow-2xl animate-slide-up"
|
||||
role="dialog"
|
||||
aria-label={label}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeadButton({
|
||||
label,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary"
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** People picker with checkboxes. */
|
||||
function PeoplePicker({
|
||||
people,
|
||||
picked,
|
||||
onToggle,
|
||||
}: {
|
||||
people: ChatPerson[];
|
||||
picked: Set<number>;
|
||||
onToggle: (id: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [q, setQ] = useState("");
|
||||
const shown = people.filter((p) =>
|
||||
`${p.displayName ?? ""} ${p.username}`.toLowerCase().includes(q.trim().toLowerCase())
|
||||
);
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
||||
<input
|
||||
className="input text-sm"
|
||||
placeholder={t("sagi.people.search")}
|
||||
aria-label={t("sagi.people.search")}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
<ul className="min-h-0 flex-1 space-y-0.5 overflow-y-auto">
|
||||
{shown.map((p) => (
|
||||
<li key={p.id}>
|
||||
<label className="flex cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 hover:bg-surface-4">
|
||||
<input type="checkbox" checked={picked.has(p.id)} onChange={() => onToggle(p.id)} />
|
||||
<UserAvatar
|
||||
name={personName(p)}
|
||||
userId={p.id}
|
||||
avatarAt={p.avatarAt}
|
||||
className="h-6 w-6 text-[10px]"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-fg-primary">
|
||||
{personName(p)}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
{!shown.length && (
|
||||
<li className="px-2 py-4 text-center text-xs text-fg-muted">{t("sagi.people.none")}</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Start a chat: one person → the direct chat; more, or a name → a group. */
|
||||
export function NewChat({
|
||||
chat,
|
||||
onOpen,
|
||||
onClose,
|
||||
}: {
|
||||
chat: ChatState;
|
||||
onOpen: (roomId: number) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [picked, setPicked] = useState<Set<number>>(new Set());
|
||||
const [name, setName] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const group = picked.size > 1 || name.trim() !== "";
|
||||
const toggle = (id: number) =>
|
||||
setPicked((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
const start = async () => {
|
||||
if (!picked.size) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const { room } = group
|
||||
? await api.chat.createGroup(name.trim(), [...picked])
|
||||
: await api.chat.openDm([...picked][0]!);
|
||||
chat.upsert(room);
|
||||
onOpen(room.id);
|
||||
} catch (e) {
|
||||
toast.error(e);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Shell label={t("sagi.people.new")}>
|
||||
<div className="pet-chat-head flex items-center gap-1 px-3 py-2.5">
|
||||
<div className="min-w-0 flex-1 text-sm font-semibold text-fg-primary">
|
||||
{t("sagi.people.new")}
|
||||
</div>
|
||||
<HeadButton label={t("sagi.people.close")} onClick={onClose}>
|
||||
<X size={16} />
|
||||
</HeadButton>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2 px-3 py-2">
|
||||
<p className="text-[11px] text-fg-muted">{t("sagi.people.newHint")}</p>
|
||||
<PeoplePicker people={chat.people} picked={picked} onToggle={toggle} />
|
||||
{picked.size > 1 && (
|
||||
<input
|
||||
className="input text-sm"
|
||||
placeholder={t("sagi.people.groupName")}
|
||||
aria-label={t("sagi.people.groupName")}
|
||||
maxLength={80}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="pet-chat-foot px-3 py-2.5">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary w-full justify-center"
|
||||
disabled={!picked.size || busy}
|
||||
onClick={() => void start()}
|
||||
>
|
||||
{group ? t("sagi.people.createGroup", { n: picked.size }) : t("sagi.people.startDm")}
|
||||
</button>
|
||||
</div>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
const URL_RE = /(https?:\/\/[^\s<>"']+)/g;
|
||||
|
||||
/** Text with its http(s) links clickable (a new tab). */
|
||||
function Linkified({ text }: { text: string }) {
|
||||
const parts = text.split(URL_RE);
|
||||
return (
|
||||
<>
|
||||
{parts.map((p, i) =>
|
||||
i % 2 === 1 ? (
|
||||
<a key={i} href={p} target="_blank" rel="noopener noreferrer" className="underline">
|
||||
{p}
|
||||
</a>
|
||||
) : (
|
||||
<span key={i}>{p}</span>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SystemLine({ m, dir }: { m: ChatMessage; dir: Map<number, ChatPerson> }) {
|
||||
const { t } = useTranslation("settings");
|
||||
const who = personName(m.userId != null ? dir.get(m.userId) : undefined);
|
||||
const [kind, ids] = m.body.split(":");
|
||||
const names = (ids ?? "")
|
||||
.split(",")
|
||||
.filter(Boolean)
|
||||
.map((id) => personName(dir.get(Number(id))))
|
||||
.join(", ");
|
||||
const text =
|
||||
kind === "added"
|
||||
? t("sagi.people.sysAdded", { who, names })
|
||||
: kind === "removed"
|
||||
? t("sagi.people.sysRemoved", { who, names })
|
||||
: kind === "left"
|
||||
? t("sagi.people.sysLeft", { who })
|
||||
: kind === "renamed"
|
||||
? t("sagi.people.sysRenamed", { who })
|
||||
: t("sagi.people.sysCreated", { who });
|
||||
return <p className="py-1 text-center text-[11px] text-fg-muted">{text}</p>;
|
||||
}
|
||||
|
||||
function Files({ m, own }: { m: ChatMessage; own: boolean }) {
|
||||
return (
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
{m.files.map((f) => {
|
||||
const url = api.chat.fileUrl(m.roomId, f.id);
|
||||
return f.type.startsWith("image/") && /png|jpeg|gif|webp/.test(f.type) ? (
|
||||
<a key={f.id} href={url} target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
src={url}
|
||||
alt={f.name}
|
||||
loading="lazy"
|
||||
className="max-h-48 max-w-full rounded-xl object-contain"
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<a
|
||||
key={f.id}
|
||||
href={url}
|
||||
download={f.name}
|
||||
className={`flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs ${own ? "bg-white/15" : "bg-surface-4"}`}
|
||||
>
|
||||
<FileText size={14} className="shrink-0" aria-hidden />
|
||||
<span className="min-w-0 flex-1 truncate">{f.name}</span>
|
||||
<span className="shrink-0 opacity-70">{formatBytes(f.size)}</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A group's settings: name, members, Leave. */
|
||||
function GroupSettings({
|
||||
chat,
|
||||
room,
|
||||
dir,
|
||||
onBack,
|
||||
onLeft,
|
||||
}: {
|
||||
chat: ChatState;
|
||||
room: ChatRoom;
|
||||
dir: Map<number, ChatPerson>;
|
||||
onBack: () => void;
|
||||
onLeft: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const owner = room.createdBy === chat.me;
|
||||
const [name, setName] = useState(room.name);
|
||||
const [adding, setAdding] = useState<Set<number>>(new Set());
|
||||
const save = async (patch: { name?: string; add?: number[]; remove?: number[] }) => {
|
||||
try {
|
||||
const r = await api.chat.updateGroup(room.id, patch);
|
||||
chat.upsert(r.room);
|
||||
setAdding(new Set());
|
||||
} catch (e) {
|
||||
toast.error(e);
|
||||
}
|
||||
};
|
||||
const leave = async () => {
|
||||
const ok = await confirmDialog({
|
||||
title: t("sagi.people.leaveTitle"),
|
||||
message: t("sagi.people.leaveBody", { name: roomTitle(room, chat.me, dir) }),
|
||||
confirmLabel: t("sagi.people.leave"),
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api.chat.leave(room.id);
|
||||
await chat.reload();
|
||||
onLeft();
|
||||
} catch (e) {
|
||||
toast.error(e);
|
||||
}
|
||||
};
|
||||
const outside = chat.people.filter((p) => !room.members.includes(p.id));
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-3 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-1 self-start text-xs text-fg-muted hover:text-fg-primary"
|
||||
>
|
||||
<ArrowLeft size={13} /> {t("sagi.people.back")}
|
||||
</button>
|
||||
{owner && (
|
||||
<form
|
||||
className="flex gap-1.5"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void save({ name });
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="input flex-1 text-sm"
|
||||
aria-label={t("sagi.people.groupName")}
|
||||
placeholder={t("sagi.people.groupName")}
|
||||
maxLength={80}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<button type="submit" className="btn-secondary" disabled={name === room.name}>
|
||||
{t("sagi.people.rename")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
<div>
|
||||
<p className="mb-1 text-[11px] uppercase tracking-wider text-fg-muted">
|
||||
{t("sagi.people.members", { n: room.members.length })}
|
||||
</p>
|
||||
<ul className="space-y-0.5">
|
||||
{room.members.map((id) => {
|
||||
const p = dir.get(id);
|
||||
return (
|
||||
<li key={id} className="flex items-center gap-2 rounded-lg px-2 py-1">
|
||||
<UserAvatar
|
||||
name={personName(p)}
|
||||
userId={id}
|
||||
avatarAt={p?.avatarAt}
|
||||
className="h-6 w-6 text-[10px]"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-fg-primary">
|
||||
{personName(p)}
|
||||
{id === room.createdBy && (
|
||||
<span className="ml-1 text-[10px] text-fg-muted">
|
||||
{t("sagi.people.creator")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{owner && id !== chat.me && (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full p-1 text-fg-muted hover:text-status-danger"
|
||||
aria-label={t("sagi.people.remove", { name: personName(p) })}
|
||||
title={t("sagi.people.remove", { name: personName(p) })}
|
||||
onClick={() => void save({ remove: [id] })}
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
{owner && outside.length > 0 && (
|
||||
<div className="flex min-h-[10rem] flex-col gap-1.5">
|
||||
<p className="text-[11px] uppercase tracking-wider text-fg-muted">
|
||||
{t("sagi.people.add")}
|
||||
</p>
|
||||
<PeoplePicker
|
||||
people={outside}
|
||||
picked={adding}
|
||||
onToggle={(id) =>
|
||||
setAdding((prev) => {
|
||||
const n = new Set(prev);
|
||||
if (n.has(id)) n.delete(id);
|
||||
else n.add(id);
|
||||
return n;
|
||||
})
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary justify-center"
|
||||
disabled={!adding.size}
|
||||
onClick={() => void save({ add: [...adding] })}
|
||||
>
|
||||
{t("sagi.people.addN", { n: adding.size })}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="mt-auto flex items-center justify-center gap-1.5 rounded-lg px-2 py-1.5 text-sm text-status-danger hover:bg-status-danger/10"
|
||||
onClick={() => void leave()}
|
||||
>
|
||||
<LogOut size={14} /> {t("sagi.people.leave")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One direct chat or group. */
|
||||
export function ChatRoomView({
|
||||
chat,
|
||||
room,
|
||||
onClose,
|
||||
onLeft,
|
||||
}: {
|
||||
chat: ChatState;
|
||||
room: ChatRoom;
|
||||
onClose: () => void;
|
||||
onLeft: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const dir = useDirectory(chat);
|
||||
const title = roomTitle(room, chat.me, dir);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [settings, setSettings] = useState(false);
|
||||
const [drag, setDrag] = useState(false);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const stick = useRef(true);
|
||||
const { markRead } = chat;
|
||||
|
||||
const read = useCallback(
|
||||
(upTo: number) => {
|
||||
markRead(room.id, upTo);
|
||||
api.chat.read(room.id, upTo).catch(() => undefined);
|
||||
},
|
||||
[room.id, markRead]
|
||||
);
|
||||
|
||||
// The room's newest page on open, then each new message as it arrives.
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
setMessages([]);
|
||||
setSettings(false);
|
||||
stick.current = true;
|
||||
api.chat
|
||||
.messages(room.id)
|
||||
.then((r) => {
|
||||
if (!live) return;
|
||||
setMessages(r.messages);
|
||||
setHasMore(r.hasMore);
|
||||
const last = r.messages[r.messages.length - 1];
|
||||
if (last) read(last.id);
|
||||
})
|
||||
.catch((e) => toast.error(e));
|
||||
chat.onMessage((m) => {
|
||||
if (m.roomId !== room.id) return;
|
||||
setMessages((prev) => (prev.some((x) => x.id === m.id) ? prev : [...prev, m]));
|
||||
if (document.visibilityState === "visible") read(m.id);
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
chat.onMessage(null);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [room.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = listRef.current;
|
||||
if (el && stick.current) el.scrollTop = el.scrollHeight;
|
||||
}, [messages]);
|
||||
|
||||
const older = async () => {
|
||||
const first = messages[0];
|
||||
if (!first) return;
|
||||
try {
|
||||
const r = await api.chat.messages(room.id, first.id);
|
||||
stick.current = false;
|
||||
setMessages((prev) => [...r.messages, ...prev]);
|
||||
setHasMore(r.hasMore);
|
||||
} catch (e) {
|
||||
toast.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const addFiles = (list: FileList | File[] | null) => {
|
||||
if (!list) return;
|
||||
const picked = [...list];
|
||||
const big = picked.find((f) => f.size > MAX_FILE_BYTES);
|
||||
if (big) toast.error(t("sagi.people.tooBig", { name: big.name }));
|
||||
setFiles((prev) =>
|
||||
[...prev, ...picked.filter((f) => f.size <= MAX_FILE_BYTES)].slice(0, MAX_FILES)
|
||||
);
|
||||
};
|
||||
|
||||
const submit = async (e?: FormEvent) => {
|
||||
e?.preventDefault();
|
||||
if ((!draft.trim() && !files.length) || sending) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const { message } = await api.chat.post(room.id, draft, files);
|
||||
stick.current = true;
|
||||
setMessages((prev) => (prev.some((x) => x.id === message.id) ? prev : [...prev, message]));
|
||||
setDraft("");
|
||||
setFiles([]);
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Shell label={t("sagi.people.roomLabel", { name: title })}>
|
||||
<div className="pet-chat-head flex items-center gap-2 px-2.5 py-2">
|
||||
<RoomIcon room={room} me={chat.me} dir={dir} className="h-7 w-7 text-[10px]" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold text-fg-primary" title={title}>
|
||||
{title}
|
||||
</div>
|
||||
{room.kind === "group" && (
|
||||
<div className="text-[10px] uppercase tracking-wider text-fg-muted">
|
||||
{t("sagi.people.members", { n: room.members.length })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{room.kind === "group" && (
|
||||
<HeadButton label={t("sagi.people.settings")} onClick={() => setSettings((v) => !v)}>
|
||||
<Settings2 size={15} />
|
||||
</HeadButton>
|
||||
)}
|
||||
<HeadButton label={t("sagi.people.close")} onClick={onClose}>
|
||||
<X size={16} />
|
||||
</HeadButton>
|
||||
</div>
|
||||
|
||||
{settings && room.kind === "group" ? (
|
||||
<GroupSettings
|
||||
chat={chat}
|
||||
room={room}
|
||||
dir={dir}
|
||||
onBack={() => setSettings(false)}
|
||||
onLeft={onLeft}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
ref={listRef}
|
||||
className={`flex-1 space-y-1.5 overflow-y-auto px-3 py-3 ${drag ? "ring-2 ring-inset ring-accent" : ""}`}
|
||||
onScroll={(e) => {
|
||||
const el = e.currentTarget;
|
||||
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (e.dataTransfer.types.includes("Files")) {
|
||||
e.preventDefault();
|
||||
setDrag(true);
|
||||
}
|
||||
}}
|
||||
onDragLeave={() => setDrag(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDrag(false);
|
||||
addFiles(e.dataTransfer.files);
|
||||
}}
|
||||
>
|
||||
{hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
className="mx-auto block text-[11px] text-fg-muted hover:text-fg-primary"
|
||||
onClick={() => void older()}
|
||||
>
|
||||
{t("sagi.people.older")}
|
||||
</button>
|
||||
)}
|
||||
{!messages.length && (
|
||||
<p className="py-10 text-center text-xs text-fg-muted">
|
||||
{t("sagi.people.empty", { name: title })}
|
||||
</p>
|
||||
)}
|
||||
{messages.map((m, i) => {
|
||||
if (m.kind === "system") return <SystemLine key={m.id} m={m} dir={dir} />;
|
||||
const own = m.userId === chat.me;
|
||||
const prev = messages[i - 1];
|
||||
const showName =
|
||||
!own &&
|
||||
room.kind === "group" &&
|
||||
(!prev || prev.userId !== m.userId || prev.kind === "system");
|
||||
return (
|
||||
<div key={m.id} className={`flex flex-col ${own ? "items-end" : "items-start"}`}>
|
||||
{showName && (
|
||||
<span className="mb-0.5 px-1 text-[10px] text-fg-muted">
|
||||
{personName(m.userId != null ? dir.get(m.userId) : undefined)}
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
className={`max-w-[85%] whitespace-pre-wrap break-words rounded-2xl px-3 py-2 text-sm shadow-sm ${
|
||||
own
|
||||
? "rounded-br-md bg-accent-solid text-white"
|
||||
: "pet-chat-reply rounded-bl-md text-fg-primary"
|
||||
}`}
|
||||
title={formatTime(m.createdAt)}
|
||||
>
|
||||
{m.body && <Linkified text={m.body} />}
|
||||
{m.files.length > 0 && <Files m={m} own={own} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="pet-chat-foot px-2.5 py-2.5">
|
||||
{files.length > 0 && (
|
||||
<ul className="mb-1.5 flex flex-wrap gap-1">
|
||||
{files.map((f, i) => (
|
||||
<li
|
||||
key={`${f.name}-${i}`}
|
||||
className="flex max-w-full items-center gap-1 rounded-full bg-surface-4 py-0.5 pl-2 pr-1 text-[11px] text-fg-secondary"
|
||||
>
|
||||
<span className="max-w-[10rem] truncate">{f.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full p-0.5 hover:text-status-danger"
|
||||
aria-label={t("sagi.people.unattach", { name: f.name })}
|
||||
onClick={() => setFiles((prev) => prev.filter((_, j) => j !== i))}
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<form onSubmit={submit} className="flex items-end gap-1.5">
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
addFiles(e.target.files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
|
||||
aria-label={t("sagi.people.attach")}
|
||||
title={t("sagi.people.attach")}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
<Paperclip size={16} />
|
||||
</button>
|
||||
<textarea
|
||||
rows={1}
|
||||
autoFocus
|
||||
className="max-h-28 min-h-[2.25rem] flex-1 resize-none rounded-2xl border border-border bg-surface-1 px-3.5 py-2 text-sm text-fg-primary placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
placeholder={t("sagi.people.placeholder", { name: title })}
|
||||
aria-label={t("sagi.people.placeholder", { name: title })}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onPaste={(e) => {
|
||||
if (e.clipboardData.files.length) {
|
||||
e.preventDefault();
|
||||
addFiles(e.clipboardData.files);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e) && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
} else if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
e.nativeEvent.stopImmediatePropagation();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={(!draft.trim() && !files.length) || sending}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent-solid text-white shadow-sm transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||
aria-label={t("sagi.panel.send")}
|
||||
>
|
||||
<ArrowUp size={16} />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,10 @@
|
||||
* shares the single WebSocket.
|
||||
* Clicking the pet opens its chat window (`PetChat`), which is its whole UI:
|
||||
* the status strip (`SagiInfo`) sits on top, a question the pet can answer
|
||||
* itself is answered there, anything else goes to the AI.
|
||||
* itself is answered there, anything else goes to the AI. In server mode a
|
||||
* rail beside it (`ChatHub.tsx`, `useChat.ts`) switches to direct chats and
|
||||
* groups with other users, and a blue dot on the pet counts their unread
|
||||
* messages.
|
||||
* Owns the open/closed chat state, the ⌘B / Esc shortcuts, reduced-motion
|
||||
* detection, and route navigation. Reactive personality + status/Ask come
|
||||
* from useSagiBrain; the avatar is draggable (AssistiveTouch-style) via
|
||||
@@ -100,6 +103,8 @@ import { startInFreeTabDetailed } from "../lanes/startSession";
|
||||
import { workspaceHref } from "../SessionRowMenu";
|
||||
import { Heart } from "lucide-react";
|
||||
import { PetChat, petChatTool, type PetLocalReply } from "./PetChat";
|
||||
import { ChatRail, ChatRoomView, NewChat } from "./ChatHub";
|
||||
import { useChat } from "./useChat";
|
||||
|
||||
// Stroking: this many left/right turns of the pointer over the pet, within
|
||||
// STROKE_MS and with no button held, is a pat; then a pause before the next.
|
||||
@@ -238,6 +243,14 @@ function SagiBody() {
|
||||
const navigate = useNavigate();
|
||||
const href = useSettingsHref();
|
||||
const brain = useSagiBrain();
|
||||
// Server mode: chat with other users beside the pet's own (ChatHub.tsx).
|
||||
const people = useChat();
|
||||
const [pane, setPane] = useState<"pet" | "new" | number>("pet");
|
||||
const room = typeof pane === "number" ? people.rooms.find((r) => r.id === pane) : undefined;
|
||||
// A room that went away (left, removed): back to the pet.
|
||||
useEffect(() => {
|
||||
if (typeof pane === "number" && people.rooms.length && !room) setPane("pet");
|
||||
}, [pane, room, people.rooms.length]);
|
||||
const place = useSagiPosition(size);
|
||||
|
||||
// Keep the pet and its size in sync with Settings / other tabs (enabled is
|
||||
@@ -416,47 +429,63 @@ function SagiBody() {
|
||||
re-attaches the same session. */}
|
||||
{open && (
|
||||
<SagiFlyout anchor={anchor} beside>
|
||||
<PetChat
|
||||
name={name}
|
||||
title={
|
||||
<PetNameButton
|
||||
<div className="flex items-start gap-2">
|
||||
{people.enabled && (
|
||||
<ChatRail chat={people} active={pane} onSelect={setPane} petName={name} />
|
||||
)}
|
||||
{people.enabled && pane === "new" ? (
|
||||
<NewChat chat={people} onOpen={setPane} onClose={() => setOpen(false)} />
|
||||
) : people.enabled && room ? (
|
||||
<ChatRoomView
|
||||
chat={people}
|
||||
room={room}
|
||||
onClose={() => setOpen(false)}
|
||||
onLeft={() => setPane("pet")}
|
||||
/>
|
||||
) : (
|
||||
<PetChat
|
||||
name={name}
|
||||
ownName={"kind" in pet ? pet.name || "Sagi" : "Sagi"}
|
||||
nickname={sagiPrefs.getNickname(pet.id)}
|
||||
onRename={(next) => sagiPrefs.setNickname(pet.id, next)}
|
||||
title={
|
||||
<PetNameButton
|
||||
name={name}
|
||||
ownName={"kind" in pet ? pet.name || "Sagi" : "Sagi"}
|
||||
nickname={sagiPrefs.getNickname(pet.id)}
|
||||
onRename={(next) => sagiPrefs.setNickname(pet.id, next)}
|
||||
/>
|
||||
}
|
||||
headerActions={
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-status-danger"
|
||||
onClick={brain.pat}
|
||||
aria-label={t("sagi.panel.pat", { name })}
|
||||
title={t("sagi.panel.pat", { name })}
|
||||
>
|
||||
<Heart size={15} />
|
||||
</button>
|
||||
}
|
||||
info={
|
||||
<SagiInfo
|
||||
attention={brain.attention}
|
||||
onOpenSession={openSession}
|
||||
status={brain.status}
|
||||
muted={brain.muted}
|
||||
onToggleMute={brain.toggleMute}
|
||||
onClearAlerts={brain.clearAlerts}
|
||||
onNavigate={onNavigate}
|
||||
defaultOpen={brain.attention.length > 0}
|
||||
/>
|
||||
}
|
||||
onLocal={onLocal}
|
||||
onClose={() => setOpen(false)}
|
||||
onOpenWorkspace={(laneId, runId) => {
|
||||
navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`);
|
||||
setOpen(false);
|
||||
}}
|
||||
onBusy={onBusy}
|
||||
/>
|
||||
}
|
||||
headerActions={
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-status-danger"
|
||||
onClick={brain.pat}
|
||||
aria-label={t("sagi.panel.pat", { name })}
|
||||
title={t("sagi.panel.pat", { name })}
|
||||
>
|
||||
<Heart size={15} />
|
||||
</button>
|
||||
}
|
||||
info={
|
||||
<SagiInfo
|
||||
attention={brain.attention}
|
||||
onOpenSession={openSession}
|
||||
status={brain.status}
|
||||
muted={brain.muted}
|
||||
onToggleMute={brain.toggleMute}
|
||||
onClearAlerts={brain.clearAlerts}
|
||||
onNavigate={onNavigate}
|
||||
defaultOpen={brain.attention.length > 0}
|
||||
/>
|
||||
}
|
||||
onLocal={onLocal}
|
||||
onClose={() => setOpen(false)}
|
||||
onOpenWorkspace={(laneId, runId) => {
|
||||
navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`);
|
||||
setOpen(false);
|
||||
}}
|
||||
onBusy={onBusy}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SagiFlyout>
|
||||
)}
|
||||
|
||||
@@ -550,6 +579,11 @@ function SagiBody() {
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{people.unread > 0 && (
|
||||
<span className="sagi-chat-dot" aria-hidden>
|
||||
{people.unread > 9 ? "9+" : people.unread}
|
||||
</span>
|
||||
)}
|
||||
{brain.status.errorCount > 0 ? (
|
||||
<span className="sagi-error-dot" aria-hidden>
|
||||
{brain.status.errorCount > 9 ? "9+" : brain.status.errorCount}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* @file ChatHub.test.tsx
|
||||
* @description The pet window's chat between users (`ChatHub.tsx`): the rail
|
||||
* lists the pet then the rooms with their unread counts; New chat opens a
|
||||
* direct chat for one person and makes a group for several; a room reads its
|
||||
* messages (a group names who wrote each), marks them read, appends one that
|
||||
* arrives live, and sends text with the attached files.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import type { ChatMessage, ChatRoom } from "../../../lib/api";
|
||||
import type { ChatState } from "../useChat";
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
chat: {
|
||||
openDm: vi.fn(),
|
||||
createGroup: vi.fn(),
|
||||
messages: vi.fn(),
|
||||
post: vi.fn(),
|
||||
read: vi.fn(),
|
||||
updateGroup: vi.fn(),
|
||||
leave: vi.fn(),
|
||||
fileUrl: (r: number, f: string) => `/api/chat/rooms/${r}/files/${f}`,
|
||||
},
|
||||
users: { avatarUrl: () => null },
|
||||
me: { avatarUrl: () => null },
|
||||
}));
|
||||
vi.mock("../../../lib/api", async (orig) => ({
|
||||
...(await orig<Record<string, unknown>>()),
|
||||
api,
|
||||
}));
|
||||
vi.mock("../../AuthGate", () => ({
|
||||
useAuth: () => ({
|
||||
state: { mode: "server", user: { id: 1, username: "me", role: "developer" } },
|
||||
}),
|
||||
}));
|
||||
|
||||
import { ChatRail, ChatRoomView, NewChat } from "../ChatHub";
|
||||
|
||||
const msg = (id: number, userId: number, body: string): ChatMessage => ({
|
||||
id,
|
||||
roomId: 7,
|
||||
userId,
|
||||
kind: "text",
|
||||
body,
|
||||
createdAt: "2026-11-03T02:00:00Z",
|
||||
files: [],
|
||||
});
|
||||
const group: ChatRoom = {
|
||||
id: 7,
|
||||
kind: "group",
|
||||
name: "Team",
|
||||
createdBy: 1,
|
||||
createdAt: "2026-11-03T01:00:00Z",
|
||||
lastMessageAt: null,
|
||||
members: [1, 2, 3],
|
||||
lastMessage: null,
|
||||
lastReadId: 0,
|
||||
unread: 2,
|
||||
};
|
||||
const dm: ChatRoom = { ...group, id: 8, kind: "dm", name: "", members: [1, 2], unread: 0 };
|
||||
|
||||
function state(over: Partial<ChatState> = {}): ChatState {
|
||||
return {
|
||||
enabled: true,
|
||||
me: 1,
|
||||
rooms: [group, dm],
|
||||
people: [
|
||||
{ id: 2, username: "an", displayName: "An", avatarAt: null },
|
||||
{ id: 3, username: "binh", displayName: null, avatarAt: null },
|
||||
],
|
||||
unread: 2,
|
||||
reload: vi.fn(async () => {}),
|
||||
upsert: vi.fn(),
|
||||
onMessage: vi.fn(),
|
||||
markRead: vi.fn(),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("ChatHub", () => {
|
||||
it("lists the pet, then each room with its unread count", () => {
|
||||
const onSelect = vi.fn();
|
||||
render(<ChatRail chat={state()} active="pet" onSelect={onSelect} petName="Sagi" />);
|
||||
expect(screen.getByRole("button", { name: "Sagi" })).toHaveAttribute("aria-pressed", "true");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Team, 2 unread" }));
|
||||
expect(onSelect).toHaveBeenCalledWith(7);
|
||||
// A direct chat is named after the other person.
|
||||
expect(screen.getByRole("button", { name: "An" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens a direct chat for one person and a group for several", async () => {
|
||||
const chat = state();
|
||||
const onOpen = vi.fn();
|
||||
api.chat.openDm.mockResolvedValue({ room: dm });
|
||||
render(<NewChat chat={chat} onOpen={onOpen} onClose={() => {}} />);
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: /An/ }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Start chat" }));
|
||||
await waitFor(() => expect(onOpen).toHaveBeenCalledWith(8));
|
||||
expect(api.chat.openDm).toHaveBeenCalledWith(2);
|
||||
|
||||
api.chat.createGroup.mockResolvedValue({ room: group });
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: /binh/ }));
|
||||
fireEvent.change(screen.getByLabelText("Group name (optional)"), { target: { value: "Team" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create group (2 people)" }));
|
||||
await waitFor(() => expect(api.chat.createGroup).toHaveBeenCalledWith("Team", [2, 3]));
|
||||
});
|
||||
|
||||
it("reads the room, names group senders, marks it read and sends with files", async () => {
|
||||
const chat = state();
|
||||
api.chat.messages.mockResolvedValue({
|
||||
messages: [msg(1, 2, "chào"), msg(2, 1, "hi")],
|
||||
hasMore: false,
|
||||
});
|
||||
api.chat.read.mockResolvedValue({ lastReadId: 2 });
|
||||
render(<ChatRoomView chat={chat} room={group} onClose={() => {}} onLeft={() => {}} />);
|
||||
expect(await screen.findByText("chào")).toBeTruthy();
|
||||
expect(screen.getByText("An")).toBeTruthy(); // sender of a group message
|
||||
expect(api.chat.read).toHaveBeenCalledWith(7, 2);
|
||||
expect(chat.markRead).toHaveBeenCalledWith(7, 2);
|
||||
|
||||
// A message arriving live is appended.
|
||||
const calls = vi.mocked(chat.onMessage).mock.calls;
|
||||
const push = calls[calls.length - 1]![0]!;
|
||||
push(msg(3, 3, "mới"));
|
||||
expect(await screen.findByText("mới")).toBeTruthy();
|
||||
|
||||
const file = new File(["x"], "a.png", { type: "image/png" });
|
||||
const box = screen.getByLabelText("Message Team");
|
||||
fireEvent.paste(box, { clipboardData: { files: [file] } });
|
||||
expect(screen.getByText("a.png")).toBeTruthy();
|
||||
fireEvent.change(box, { target: { value: "xem nè" } });
|
||||
api.chat.post.mockResolvedValue({ message: { ...msg(4, 1, "xem nè") } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /send/i }));
|
||||
await waitFor(() => expect(api.chat.post).toHaveBeenCalledWith(7, "xem nè", [file]));
|
||||
expect(await screen.findByText("xem nè")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -200,6 +200,20 @@ describe("Sagi widget", () => {
|
||||
expect(within(liveChip).getByText("2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("counts only the viewer's own sessions (another user's arrive marked mine: false)", () => {
|
||||
renderSagi();
|
||||
act(() => {
|
||||
eventBus.publish({ ...sessionMsg("mine", "active"), mine: true });
|
||||
eventBus.publish({ ...sessionMsg("theirs", "active"), mine: false });
|
||||
eventBus.publish({ ...sessionMsg("theirs-err", "error"), mine: false });
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
const panel = screen.getByRole("dialog", { name: /chat with sagi/i });
|
||||
const liveChip = within(panel).getByText("live").closest("span.rounded-full")! as HTMLElement;
|
||||
expect(within(liveChip).getByText("1")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/theirs/)).toBeNull();
|
||||
});
|
||||
|
||||
it("respects the enabled preference", () => {
|
||||
localStorage.setItem("agent-dashboard-sagi-enabled", "false");
|
||||
renderSagi();
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
* infinite animation makes the browser draw a new frame 60 times a second for
|
||||
* as long as the page is open (the drop-shadow filter re-rendered each time),
|
||||
* which made the whole desktop stutter. All motion is disabled under
|
||||
* [data-reduced="1"] and prefers-reduced-motion.
|
||||
* [data-reduced="1"] and prefers-reduced-motion. The blue dot counts unread
|
||||
* chat messages from other users (server mode).
|
||||
* Transform origins are the delicate part - see the comments on .sagi-avatar
|
||||
* and .sagi-eye-blink for which reference box each one resolves against.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -406,6 +407,25 @@
|
||||
outline: 2px solid #818cf8;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
/* Unread chat messages from other users: top-left, opposite the error dot. */
|
||||
.sagi-chat-dot {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
min-width: 14px;
|
||||
height: 14px;
|
||||
padding: 0 2px;
|
||||
border-radius: 9999px;
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid #0c0c14;
|
||||
}
|
||||
|
||||
.sagi-error-dot {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* @file useChat.ts
|
||||
* @description The pet window's chat between users (server mode only): the
|
||||
* signed-in user's rooms (`GET /api/chat/rooms`), the people they can start a
|
||||
* chat with (`GET /api/chat/people`, read once), and the total unread count
|
||||
* for the badge on the pet. A `chat_message` frame updates its room's newest
|
||||
* message and unread count in place and is handed to the open room's view
|
||||
* (`onMessage`); a `chat_room` frame (made, renamed, members changed, left)
|
||||
* re-reads the list. Off in local mode — one person has no one to chat with.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { api, type ChatMessage, type ChatPerson, type ChatRoom } from "../../lib/api";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { useAuth } from "../AuthGate";
|
||||
import type { WSMessage } from "../../lib/types";
|
||||
|
||||
export type ChatState = {
|
||||
/** Server mode with a login: the chat exists. */
|
||||
enabled: boolean;
|
||||
me: number | null;
|
||||
rooms: ChatRoom[];
|
||||
people: ChatPerson[];
|
||||
unread: number;
|
||||
reload: () => Promise<void>;
|
||||
/** A room changed here (opened, renamed, read): put it in the list now. */
|
||||
upsert: (room: ChatRoom) => void;
|
||||
/** The open room's view, told of each new message for it. */
|
||||
onMessage: (fn: ((m: ChatMessage) => void) | null) => void;
|
||||
/** Mark a room read locally (the view also tells the server). */
|
||||
markRead: (roomId: number, upTo: number) => void;
|
||||
};
|
||||
|
||||
export function useChat(): ChatState {
|
||||
const { state } = useAuth();
|
||||
const me = state.mode === "server" && "user" in state && state.user ? state.user.id : null;
|
||||
const enabled = me != null;
|
||||
const [rooms, setRooms] = useState<ChatRoom[]>([]);
|
||||
const [people, setPeople] = useState<ChatPerson[]>([]);
|
||||
const listener = useRef<((m: ChatMessage) => void) | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
if (!enabled) return;
|
||||
try {
|
||||
setRooms((await api.chat.rooms()).rooms);
|
||||
} catch {
|
||||
// The pet's chat with the AI still works; the list retries on the next frame.
|
||||
}
|
||||
}, [enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
void reload();
|
||||
api.chat
|
||||
.people()
|
||||
.then((r) => setPeople(r.people))
|
||||
.catch(() => undefined);
|
||||
}, [enabled, reload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const off = eventBus.subscribe((msg: WSMessage) => {
|
||||
if (msg.type === "chat_room") {
|
||||
void reload();
|
||||
return;
|
||||
}
|
||||
if (msg.type !== "chat_message") return;
|
||||
const m = (msg.data as { message?: ChatMessage }).message;
|
||||
if (!m) return;
|
||||
let known = false;
|
||||
setRooms((prev) => {
|
||||
const next = prev.map((r) => {
|
||||
if (r.id !== m.roomId) return r;
|
||||
known = true;
|
||||
const own = m.userId === me;
|
||||
return {
|
||||
...r,
|
||||
lastMessage: m,
|
||||
lastMessageAt: m.createdAt,
|
||||
unread: own ? 0 : r.unread + 1,
|
||||
lastReadId: own ? m.id : r.lastReadId,
|
||||
};
|
||||
});
|
||||
return next.sort((a, b) =>
|
||||
(b.lastMessageAt ?? b.createdAt).localeCompare(a.lastMessageAt ?? a.createdAt)
|
||||
);
|
||||
});
|
||||
// A room this list has not seen yet (added a moment ago): read it all.
|
||||
if (!known) void reload();
|
||||
listener.current?.(m);
|
||||
});
|
||||
// Reconnected: frames may have been missed.
|
||||
const offConn = eventBus.onConnection((up) => {
|
||||
if (up) void reload();
|
||||
});
|
||||
return () => {
|
||||
off();
|
||||
offConn();
|
||||
};
|
||||
}, [enabled, me, reload]);
|
||||
|
||||
const upsert = useCallback((room: ChatRoom) => {
|
||||
setRooms((prev) => [room, ...prev.filter((r) => r.id !== room.id)]);
|
||||
}, []);
|
||||
const onMessage = useCallback((fn: ((m: ChatMessage) => void) | null) => {
|
||||
listener.current = fn;
|
||||
}, []);
|
||||
const markRead = useCallback((roomId: number, upTo: number) => {
|
||||
setRooms((prev) =>
|
||||
prev.map((r) =>
|
||||
r.id === roomId ? { ...r, unread: 0, lastReadId: Math.max(r.lastReadId, upTo) } : r
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
const unread = useMemo(() => rooms.reduce((n, r) => n + r.unread, 0), [rooms]);
|
||||
return { enabled, me, rooms, people, unread, reload, upsert, onMessage, markRead };
|
||||
}
|
||||
@@ -7,7 +7,8 @@
|
||||
* burst of the same event into one count), the pet's one-shot animation
|
||||
* "moment", and imperative controls (mute, clear alerts, pat, set thinking)
|
||||
* for the UI shell. It also greets once per browser session and makes an
|
||||
* idle pet fidget now and then.
|
||||
* idle pet fidget now and then. It only ever counts the viewer's OWN sessions
|
||||
* (`mine=1` seed, and frames marked `mine: false` skipped).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -278,7 +279,7 @@ export function useSagiBrain(): SagiBrain {
|
||||
}, GREET_DELAY_MS);
|
||||
};
|
||||
api.sessions
|
||||
.list({ status: "active", limit: 100 })
|
||||
.list({ status: "active", limit: 100, mine: true })
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
update((prev) => seedSessions(prev, res.sessions, Date.now()));
|
||||
@@ -294,6 +295,9 @@ export function useSagiBrain(): SagiBrain {
|
||||
// Subscribe to the live stream and connection status.
|
||||
useEffect(() => {
|
||||
const unsubMsg = eventBus.subscribe((msg: WSMessage) => {
|
||||
// Each user's pet tells only of their own sessions: an admin/analyst
|
||||
// gets every user's, marked `mine: false` when not theirs.
|
||||
if (msg.mine === false) return;
|
||||
const prev = stateRef.current;
|
||||
const { state: next, pulse, subject } = reduceSagi(prev, msg, Date.now());
|
||||
// Most frames only move lastActivityAt, which the moods read at a 1 s
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* which the sidebar owns and which also carries the update check), the version
|
||||
* — with a green dot while a newer release is out —, how many SESSIONS are
|
||||
* running and how many need the user (`GET /api/sessions?status=active` and
|
||||
* `?awaiting=1` — the Dashboard's Needs-you rule —, re-read on session
|
||||
* `?awaiting=1` — the Dashboard's Needs-you rule —, both `&mine=1`: the user's
|
||||
* OWN sessions even for an admin —, re-read on session
|
||||
* messages), and today's spend — `GET /api/pricing/cost?date=<local date>&mine=1`, the
|
||||
* user's OWN spend even for an admin — and, on the
|
||||
* Workspace page, the open workspace's total (`?lane=` on the same call: the
|
||||
@@ -388,11 +389,11 @@ export function StatusBar({
|
||||
|
||||
const loadCounts = useCallback(() => {
|
||||
api.sessions
|
||||
.list({ status: "active", limit: 1 })
|
||||
.list({ status: "active", limit: 1, mine: true })
|
||||
.then((r) => setActive(r.total))
|
||||
.catch(() => undefined);
|
||||
api.sessions
|
||||
.list({ awaiting: true, limit: 1 })
|
||||
.list({ awaiting: true, limit: 1, mine: true })
|
||||
.then((r) => setNeedYou(r.total))
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
@@ -183,6 +183,9 @@ describe("StatusBar", () => {
|
||||
);
|
||||
await screen.findByText("1 need you");
|
||||
expect(screen.getByTestId("status-bar-counts").textContent).toBe("2 running · 1 need you");
|
||||
// The user's OWN sessions, even for an admin who sees every user's rows.
|
||||
for (const [p] of vi.mocked(api.sessions.list).mock.calls)
|
||||
expect(p).toMatchObject({ mine: true });
|
||||
await user.click(conn);
|
||||
expect(onConnectionClick).toHaveBeenCalled();
|
||||
// Polite live region: the connection and who is waiting, nothing that ticks.
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
|
||||
import React from "react";
|
||||
import { CodeBlock } from "./CodeBlock";
|
||||
import { onLinkClick } from "../../lib/openInPreview";
|
||||
|
||||
type Block =
|
||||
| { kind: "code"; lang: string; code: string }
|
||||
@@ -305,7 +306,13 @@ const LABEL_CLASS = (word: string) =>
|
||||
|
||||
function UrlLink({ url }: { url: string }) {
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" className={LINK_CLASS}>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => onLinkClick(e, url)}
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
{url}
|
||||
</a>
|
||||
);
|
||||
@@ -466,6 +473,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => onLinkClick(e, href)}
|
||||
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"
|
||||
>
|
||||
{renderInline(linkM[1]!, `${baseKey}-l${n}`)}
|
||||
|
||||
@@ -865,7 +865,7 @@ function MessageListView({
|
||||
className={
|
||||
compact
|
||||
? "space-y-3 py-1 pr-2"
|
||||
: "mx-auto flex w-full max-w-3xl flex-col gap-3 px-4 py-6"
|
||||
: "mx-auto flex w-full max-w-5xl flex-col gap-3 px-4 py-6"
|
||||
}
|
||||
>
|
||||
{items.map((item) => {
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
* GPUs), disks (how full, read / write rate) with network interfaces, and the
|
||||
* busiest processes with who each counts for (history, CPU threads and
|
||||
* processes are Linux-only reads, so off Linux those cards are left out); and — server mode — each user's
|
||||
* live CPU / RAM share (a tile per user; click for their busiest processes). Also exports `useHostStats` (a poll every `pollMs` while
|
||||
* live CPU / RAM share (a tile per user; click for their busiest processes; hover
|
||||
* for — admin/analyst, from `GET /api/users` — their role, $ spent today,
|
||||
* workspaces, sessions and last sign-in). Also exports `useHostStats` (a poll every `pollMs` while
|
||||
* the tab is visible; 0 = read once), `Meter`, `pct` and `fmtPct`, which the
|
||||
* Administration users table shares for each home's share of the homes disk.
|
||||
* Read-only.
|
||||
@@ -38,14 +40,17 @@ import {
|
||||
type HostHistoryRange,
|
||||
type HostStats,
|
||||
type HostUsageGroup,
|
||||
type ServerUserUsage,
|
||||
} from "../../lib/api";
|
||||
import { formatBytes, formatDateTime } from "../../lib/format";
|
||||
import { fmt, fmtCost, formatBytes, formatDateTime } from "../../lib/format";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { Tip } from "../Tip";
|
||||
import { MiniChart } from "../admin/MiniChart";
|
||||
import { UserAvatar } from "../users/UserAvatar";
|
||||
|
||||
const POLL_MS = 5000;
|
||||
const HISTORY_POLL_MS = 30000;
|
||||
const USERS_POLL_MS = 60000;
|
||||
const RANGES: HostHistoryRange[] = ["1h", "6h", "24h"];
|
||||
|
||||
export const pct = (part: number, whole: number) => (whole > 0 ? (part / whole) * 100 : 0);
|
||||
@@ -324,6 +329,25 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
|
||||
const [history, setHistory] = useState<HostHistory | null>(null);
|
||||
// The user tile whose busiest processes are shown.
|
||||
const [open, setOpen] = useState<string | null>(null);
|
||||
// Each account's role and spend for the tiles' hover card. `GET /api/users`
|
||||
// is admin/analyst only, so a developer gets no card.
|
||||
const { seesAll, local } = usePermissions();
|
||||
const { t: tu } = useTranslation("users");
|
||||
const [accounts, setAccounts] = useState<Map<number, ServerUserUsage>>(new Map());
|
||||
const hasPeople = (stats?.usage?.groups ?? []).some((g) => g.kind === "user");
|
||||
useEffect(() => {
|
||||
if (!seesAll || local || !hasPeople) return;
|
||||
const load = () =>
|
||||
api.users
|
||||
.list()
|
||||
.then((r) => setAccounts(new Map(r.users.map((u) => [u.id, u]))))
|
||||
.catch(() => {});
|
||||
void load();
|
||||
const id = setInterval(() => {
|
||||
if (document.visibilityState === "visible") void load();
|
||||
}, USERS_POLL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [seesAll, local, hasPeople]);
|
||||
|
||||
const loadHistory = useCallback(
|
||||
() =>
|
||||
@@ -365,6 +389,29 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
|
||||
if (key === "system") return t("host.systemGroup");
|
||||
return key;
|
||||
};
|
||||
const userTip = (g: HostUsageGroup) => {
|
||||
const u = g.userId != null ? accounts.get(g.userId) : undefined;
|
||||
// No account row (a developer, or not read yet): no card at all.
|
||||
if (!u) return "";
|
||||
const lines = [groupName(g)];
|
||||
{
|
||||
lines.push(
|
||||
t("host.userTipRole", { v: tu(`roles.${u.role}`) }) +
|
||||
(u.disabled ? ` · ${t("host.userTipDisabled")}` : "")
|
||||
);
|
||||
if (u.today)
|
||||
lines.push(
|
||||
t("host.userTipToday", { cost: fmtCost(u.today.cost), tokens: fmt(u.today.tokens) })
|
||||
);
|
||||
lines.push(t("host.userTipWork", { w: u.workspaces, s: u.sessions }));
|
||||
lines.push(
|
||||
t("host.userTipLastLogin", {
|
||||
v: u.lastLoginAt ? formatDateTime(u.lastLoginAt) : t("host.userTipNever"),
|
||||
})
|
||||
);
|
||||
}
|
||||
return lines.join("\n");
|
||||
};
|
||||
const perSec = (v: number) => t("host.perSecond", { v: formatBytes(v) });
|
||||
const timeLabel = (iso: string) =>
|
||||
new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
@@ -676,26 +723,30 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
|
||||
const isOpen = open === g.key;
|
||||
return (
|
||||
<div key={g.key} className="space-y-2" data-testid="server-user">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(isOpen ? null : g.key)}
|
||||
disabled={!g.top.length}
|
||||
aria-expanded={isOpen}
|
||||
className="flex w-full items-center gap-2 text-left disabled:cursor-default"
|
||||
>
|
||||
<UserAvatar
|
||||
name={name}
|
||||
userId={g.userId}
|
||||
avatarAt={g.avatarAt}
|
||||
className="h-6 w-6 text-[10px]"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-fg-secondary">
|
||||
{name}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-fg-muted">
|
||||
{t("host.processCount", { n: g.processes })}
|
||||
</span>
|
||||
</button>
|
||||
{/* The hover card sits on the name row only: over the whole
|
||||
tile it stacked on the CPU bar's own help tip. */}
|
||||
<Tip raw={userTip(g)} block>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(isOpen ? null : g.key)}
|
||||
disabled={!g.top.length}
|
||||
aria-expanded={isOpen}
|
||||
className="flex w-full items-center gap-2 text-left disabled:cursor-default"
|
||||
>
|
||||
<UserAvatar
|
||||
name={name}
|
||||
userId={g.userId}
|
||||
avatarAt={g.avatarAt}
|
||||
className="h-6 w-6 text-[10px]"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-fg-secondary">
|
||||
{name}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-fg-muted">
|
||||
{t("host.processCount", { n: g.processes })}
|
||||
</span>
|
||||
</button>
|
||||
</Tip>
|
||||
<HealthBar label="CPU" value={g.cpu} tip={t("host.cpuHelp")} />
|
||||
<HealthBar label={`RAM · ${formatBytes(g.memory)}`} value={g.memoryPercent} />
|
||||
{isOpen && (
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
* rate and each network interface, the CPU / memory history with its range
|
||||
* switch (`GET /api/host/history`), and the busiest processes with who each
|
||||
* counts for, the processes of no user in a short list of their own, and
|
||||
* each user's live CPU / RAM as a tile (their busiest processes on click);
|
||||
* each user's live CPU / RAM as a tile (their busiest processes on click; on
|
||||
* hover, for staff, their role and $ spent today);
|
||||
* off Linux the cards it can never fill are left out.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -69,8 +70,10 @@ const stats: HostStats = {
|
||||
const api = vi.hoisted(() => ({
|
||||
host: { stats: vi.fn(), history: vi.fn() },
|
||||
me: { avatarUrl: () => null },
|
||||
users: { avatarUrl: () => null },
|
||||
users: { avatarUrl: () => null, list: vi.fn() },
|
||||
}));
|
||||
const perms = vi.hoisted(() => ({ seesAll: true, local: true, isAdmin: true }));
|
||||
vi.mock("../../AuthGate", () => ({ usePermissions: () => perms }));
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return { ...actual, api };
|
||||
@@ -105,6 +108,7 @@ const full: HostStats = {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
Object.assign(perms, { seesAll: true, local: true, isAdmin: true });
|
||||
api.host.stats.mockResolvedValue(full);
|
||||
api.host.history.mockResolvedValue({
|
||||
range: "1h",
|
||||
@@ -182,6 +186,50 @@ describe("HostHealth", () => {
|
||||
expect(within(tile).getByText("claude")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("names the role and today's spend when a user tile is hovered", async () => {
|
||||
Object.assign(perms, { local: false });
|
||||
api.users.list.mockResolvedValue({
|
||||
users: [
|
||||
{
|
||||
id: 2,
|
||||
username: "mia",
|
||||
role: "developer",
|
||||
disabled: false,
|
||||
workspaces: 2,
|
||||
sessions: 5,
|
||||
today: { cost: 1.5, tokens: 12000 },
|
||||
last7d: { cost: 0, tokens: 0 },
|
||||
last30d: { cost: 0, tokens: 0 },
|
||||
lastLoginAt: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
render(<Tab />);
|
||||
const tile = await screen.findByTestId("server-user");
|
||||
// The card exists only once the users list has arrived.
|
||||
const nameRow = () => within(tile).getByRole("button").parentElement!;
|
||||
const tip = await vi.waitFor(() => {
|
||||
fireEvent.mouseEnter(nameRow());
|
||||
return screen.getByText(/Role: Developer/);
|
||||
});
|
||||
expect(tip.textContent).not.toContain("Login");
|
||||
expect(tip.textContent).toMatch(/Today: \$1\.5/);
|
||||
expect(tip.textContent).toContain("2 workspaces · 5 sessions");
|
||||
// Only the name row carries it: the CPU bar keeps its own help tip alone.
|
||||
fireEvent.mouseLeave(within(tile).getByRole("button").parentElement!);
|
||||
fireEvent.mouseEnter(within(tile).getByRole("meter", { name: "CPU" }));
|
||||
expect(screen.queryByText(/Role: Developer/)).toBeNull();
|
||||
});
|
||||
|
||||
it("gives a developer no hover card, without reading the users list", async () => {
|
||||
Object.assign(perms, { seesAll: false, local: false, isAdmin: false });
|
||||
render(<Tab />);
|
||||
const tile = await screen.findByTestId("server-user");
|
||||
fireEvent.mouseEnter(within(tile).getByRole("button").parentElement!);
|
||||
expect(screen.queryByText(/Today:/)).toBeNull();
|
||||
expect(api.users.list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves out what Windows cannot report instead of empty cards", async () => {
|
||||
api.host.stats.mockResolvedValue({
|
||||
...stats,
|
||||
|
||||
@@ -91,6 +91,7 @@ import {
|
||||
type FsPlace,
|
||||
type GitSource,
|
||||
isHomePlace,
|
||||
newUploadId,
|
||||
} from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
@@ -529,7 +530,13 @@ export function FileBrowser({
|
||||
await fn();
|
||||
if (done) toast.success(done);
|
||||
} catch (e) {
|
||||
toast.error(errCode(e) === "EUPGRADE" ? t("fx.needsUpgrade") : errText(e));
|
||||
toast.error(
|
||||
errCode(e) === "EUPGRADE"
|
||||
? t("fx.needsUpgrade")
|
||||
: errCode(e) === "ESECRET"
|
||||
? t("fx.secret")
|
||||
: errText(e)
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
changed();
|
||||
@@ -682,13 +689,21 @@ export function FileBrowser({
|
||||
// false once the server says a batch cannot be written here (an old helper).
|
||||
let batchable = true;
|
||||
const show = (name: string, extra = 0) => job.update({ name, done, sent: sent + extra });
|
||||
const onWait = (waiting: boolean) => job.update({ waiting });
|
||||
/** One file; false = the user cancelled. */
|
||||
const one = async ({ file, rel }: { file: File; rel: string }) => {
|
||||
if (signal.aborted) return false;
|
||||
const dest = join(dir, rel);
|
||||
show(rel);
|
||||
// One id for every attempt: a retry with another `conflict` reuses the staged bytes.
|
||||
const upload = newUploadId();
|
||||
const send = (c: FsConflict) =>
|
||||
api.files.upload(place, dest, file, c, (n) => show(rel, n), signal);
|
||||
api.files.uploadStaged(place, dest, file, c, {
|
||||
upload,
|
||||
onProgress: (n) => show(rel, n),
|
||||
onWait,
|
||||
signal,
|
||||
});
|
||||
try {
|
||||
const r = await send(forAll ?? "fail").catch(async (e) => {
|
||||
if (errCode(e) !== "EEXISTS") throw e;
|
||||
@@ -701,6 +716,9 @@ export function FileBrowser({
|
||||
} catch (e) {
|
||||
if (errCode(e) === "ECANCEL" || errCode(e) === "EABORT") return false;
|
||||
toast.error(t("fx.uploadFailed", { name: rel, message: errText(e) }));
|
||||
} finally {
|
||||
onWait(false);
|
||||
void api.files.dropUpload(place, upload);
|
||||
}
|
||||
sent += file.size;
|
||||
done++;
|
||||
@@ -720,6 +738,7 @@ export function FileBrowser({
|
||||
return a.choice;
|
||||
};
|
||||
if (signal.aborted) return "cancel";
|
||||
const upload = newUploadId();
|
||||
try {
|
||||
const c = await api.files.uploadCheck(
|
||||
place,
|
||||
@@ -733,7 +752,12 @@ export function FileBrowser({
|
||||
let policy = await choose(c.taken);
|
||||
if (!policy) return "cancel";
|
||||
const send = (p: FsConflict) =>
|
||||
api.files.uploadBatch(place, dir, b, p, (n) => show(label, n), signal);
|
||||
api.files.uploadBatchStaged(place, dir, b, p, {
|
||||
upload,
|
||||
onProgress: (n) => show(label, n),
|
||||
onWait,
|
||||
signal,
|
||||
});
|
||||
const r = await send(policy).catch(async (e) => {
|
||||
// Taken meanwhile: ask about those, then once more.
|
||||
if (errCode(e) !== "EEXISTS") throw e;
|
||||
@@ -752,6 +776,9 @@ export function FileBrowser({
|
||||
return "fallback";
|
||||
}
|
||||
toast.error(t("fx.uploadFailed", { name: label, message: errText(e) }));
|
||||
} finally {
|
||||
onWait(false);
|
||||
void api.files.dropUpload(place, upload);
|
||||
}
|
||||
sent += size;
|
||||
done += b.length;
|
||||
@@ -1693,7 +1720,6 @@ export function FileBrowser({
|
||||
<span className="ml-auto hidden md:inline">{t("fx.dropHint")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{ctx.menu && (
|
||||
|
||||
@@ -489,8 +489,9 @@ function FileActions({
|
||||
title={t("changes.discardConfirm")}
|
||||
disabled={busy}
|
||||
onClick={() => onAct("discard", f.path, true)}
|
||||
className={`${HUNK_CHIP} border-status-danger bg-status-danger/20 text-status-danger`}
|
||||
className="flex shrink-0 items-center gap-1 rounded bg-status-danger/10 px-1 py-0.5 text-[11px] leading-3 text-status-danger hover:bg-status-danger/20 disabled:opacity-40"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
{t("changes.discardShortConfirm")}
|
||||
</button>
|
||||
) : (
|
||||
|
||||
@@ -373,12 +373,17 @@ export function LaneGitHeader({
|
||||
className="flex min-w-0 flex-1 items-baseline gap-1.5 font-mono text-[11px]"
|
||||
title={`${git.head} ${git.subject}`}
|
||||
>
|
||||
<span className="flex shrink-0 items-center gap-1 self-center font-semibold text-fg-primary">
|
||||
<GitBranch className="h-3 w-3 text-fg-muted" />
|
||||
{git.branch}
|
||||
{/* A long branch name truncates too (after the subject has gone),
|
||||
or it overflowed onto the head and the action icons. */}
|
||||
<span
|
||||
className="flex min-w-[5rem] items-center gap-1 self-center font-semibold text-fg-primary"
|
||||
title={git.branch}
|
||||
>
|
||||
<GitBranch className="h-3 w-3 shrink-0 text-fg-muted" />
|
||||
<span className="truncate">{git.branch}</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-fg-muted">{git.head}</span>
|
||||
<span className="min-w-0 truncate text-fg-muted">{git.subject}</span>
|
||||
<span className="min-w-0 shrink-[999] truncate text-fg-muted">{git.subject}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -145,7 +145,7 @@ export function SessionReplay({
|
||||
{livePane ? (
|
||||
<div
|
||||
data-testid="session-replay-live"
|
||||
className="mx-auto flex w-full max-w-3xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
|
||||
className="mx-auto flex w-full max-w-5xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
|
||||
>
|
||||
<span className="min-w-0 flex-1">{t("replay.live")}</span>
|
||||
<button
|
||||
@@ -160,7 +160,7 @@ export function SessionReplay({
|
||||
) : session && !resumable ? (
|
||||
<div
|
||||
data-testid="session-replay-no-resume"
|
||||
className="mx-auto flex w-full max-w-3xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
|
||||
className="mx-auto flex w-full max-w-5xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
|
||||
>
|
||||
<span className="min-w-0 flex-1">{t("replay.noResume", { tool })}</span>
|
||||
<button
|
||||
@@ -172,7 +172,7 @@ export function SessionReplay({
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-auto w-full max-w-3xl rounded-2xl border border-border p-2 shadow-sm transition-colors focus-within:border-accent/50">
|
||||
<div className="mx-auto w-full max-w-5xl rounded-2xl border border-border p-2 shadow-sm transition-colors focus-within:border-accent/50">
|
||||
<PromptEditor
|
||||
bare
|
||||
images={images}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @file UrlPreview.tsx
|
||||
* @description The session panel's Preview tab: a web page (a lane's dev
|
||||
* server, mostly) shown beside the console. One bar — address, Reload, Open
|
||||
* in a browser tab — and, before anything is open, the workspace's own ports
|
||||
* as one-click chips. The page runs in an `<iframe src>` on ITS OWN origin:
|
||||
* `normalizePreviewUrl` refuses the dashboard's origin and anything but
|
||||
* http(s), and the sandbox leaves out top navigation so a page cannot move
|
||||
* the dashboard away. A site that forbids framing (most login pages) shows
|
||||
* blank or as the browser's broken-page icon; that is the browser's rule and
|
||||
* cannot be read from here, so any page not on this machine gets a strip
|
||||
* saying so with the Open button beside it. The
|
||||
* last address is remembered per workspace in localStorage (a viewer
|
||||
* convenience, never the server's).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ExternalLink, Globe, RotateCw } from "lucide-react";
|
||||
import { isLoopbackUrl, normalizePreviewUrl } from "../../lib/openInPreview";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { toast } from "../../lib/toast";
|
||||
|
||||
const KEY = (laneId: number) => `agenthub.preview.url.${laneId}`;
|
||||
|
||||
function readSaved(laneId: number): string {
|
||||
try {
|
||||
return window.localStorage.getItem(KEY(laneId)) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** The frame's sandbox: everything a web app needs, minus moving the
|
||||
* dashboard (`allow-top-navigation*`). `allow-same-origin` is the PAGE's
|
||||
* own origin — never the dashboard's, which `normalizePreviewUrl` refuses. */
|
||||
const FRAME_SANDBOX =
|
||||
"allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-modals allow-downloads";
|
||||
|
||||
export function UrlPreview({
|
||||
laneId,
|
||||
ports,
|
||||
request,
|
||||
}: {
|
||||
laneId: number;
|
||||
/** The lane's bound ports, by name — the chips of the empty state. */
|
||||
ports: Record<string, number>;
|
||||
/** A link asked for this page (`openInPreview`); a new nonce re-opens it. */
|
||||
request: { url: string; nonce: number } | null;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const [url, setUrl] = useState(() => readSaved(laneId));
|
||||
const [draft, setDraft] = useState(url);
|
||||
const [reload, setReload] = useState(0);
|
||||
|
||||
// Another workspace: its own last page.
|
||||
useEffect(() => {
|
||||
const saved = readSaved(laneId);
|
||||
setUrl(saved);
|
||||
setDraft(saved);
|
||||
}, [laneId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (request) go(request.url);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the nonce
|
||||
}, [request?.nonce]);
|
||||
|
||||
function go(raw: string) {
|
||||
const next = normalizePreviewUrl(raw);
|
||||
if (!next) {
|
||||
toast.error(t("preview.badUrl"));
|
||||
return;
|
||||
}
|
||||
setUrl(next);
|
||||
setDraft(next);
|
||||
setReload((n) => n + 1);
|
||||
try {
|
||||
window.localStorage.setItem(KEY(laneId), next);
|
||||
} catch {
|
||||
/* disabled storage: the tab just forgets between visits */
|
||||
}
|
||||
}
|
||||
|
||||
const openTab = () => url && window.open(url, "_blank", "noopener,noreferrer");
|
||||
const portList = Object.entries(ports ?? {}).filter(([, p]) => Number.isInteger(p) && p > 0);
|
||||
const btn =
|
||||
"flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40 disabled:hover:bg-transparent";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col" data-testid="url-preview">
|
||||
<div className="flex shrink-0 items-center gap-1 border-b border-border px-2 py-1.5">
|
||||
<input
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
go(draft);
|
||||
}
|
||||
}}
|
||||
placeholder="localhost:3000"
|
||||
aria-label={t("preview.address")}
|
||||
spellCheck={false}
|
||||
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={btn}
|
||||
disabled={!url}
|
||||
onClick={() => setReload((n) => n + 1)}
|
||||
title={t("preview.reload")}
|
||||
aria-label={t("preview.reload")}
|
||||
>
|
||||
<RotateCw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={btn}
|
||||
disabled={!url}
|
||||
onClick={openTab}
|
||||
title={t("preview.openTab")}
|
||||
aria-label={t("preview.openTab")}
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{url && !isLoopbackUrl(url) && (
|
||||
// An outside site: most refuse to be framed, and a refusal cannot be
|
||||
// detected from here — so say it, with the way out beside it.
|
||||
<div
|
||||
data-testid="url-preview-blocked-hint"
|
||||
className="flex shrink-0 items-center gap-2 border-b border-border bg-surface-2 px-2 py-1.5 text-[11px] text-fg-secondary"
|
||||
>
|
||||
<span className="min-w-0 flex-1">{t("preview.blockedHint")}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openTab}
|
||||
className="flex shrink-0 items-center gap-1 rounded-md px-1.5 py-0.5 font-medium text-accent hover:bg-surface-4"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
{t("preview.openTabShort")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{url ? (
|
||||
<iframe
|
||||
key={`${url}#${reload}`}
|
||||
src={url}
|
||||
title={t("preview.title")}
|
||||
sandbox={FRAME_SANDBOX}
|
||||
referrerPolicy="no-referrer"
|
||||
className="min-h-0 w-full flex-1 border-0 bg-white"
|
||||
data-testid="url-preview-frame"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
|
||||
<Globe className="h-6 w-6 text-fg-muted" />
|
||||
<p className="text-xs text-fg-secondary">{t("preview.empty")}</p>
|
||||
{portList.length > 0 && (
|
||||
<div className="flex flex-wrap justify-center gap-1.5">
|
||||
{portList.map(([name, port]) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => go(`localhost:${port}`)}
|
||||
title={name}
|
||||
className="rounded-full border border-border px-2.5 py-0.5 font-mono text-[11px] text-fg-secondary transition-colors hover:bg-surface-4 hover:text-fg-primary"
|
||||
>
|
||||
localhost:{port}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[11px] text-fg-muted">{t("preview.loginHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,10 @@ vi.mock("../../../lib/api", () => ({
|
||||
gitTarget: (lane: number, session?: string | null, source?: number | null) =>
|
||||
source ? { lane, source } : session ? { lane, session } : lane,
|
||||
isHomePlace: (p: unknown) => typeof p === "object" && p !== null && "home" in p,
|
||||
newUploadId: (() => {
|
||||
let n = 0;
|
||||
return () => `upload-id-${String(++n).padStart(8, "0")}`;
|
||||
})(),
|
||||
api: {
|
||||
lanes: {
|
||||
list: vi.fn(),
|
||||
@@ -52,8 +56,9 @@ vi.mock("../../../lib/api", () => ({
|
||||
allTrash: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
purge: vi.fn(),
|
||||
upload: vi.fn(),
|
||||
uploadBatch: vi.fn(),
|
||||
uploadStaged: vi.fn(),
|
||||
uploadBatchStaged: vi.fn(),
|
||||
dropUpload: vi.fn(async () => null),
|
||||
uploadCheck: vi.fn(),
|
||||
zipUrl: (t: number, ps: string[]) => `/api/lanes/${t}/fs/zip?p=${ps.join("&p=")}`,
|
||||
},
|
||||
@@ -236,7 +241,7 @@ describe("FileExplorer", () => {
|
||||
});
|
||||
|
||||
it("asks about a taken name on upload, and sends the choice", async () => {
|
||||
vi.mocked(api.files.upload)
|
||||
vi.mocked(api.files.uploadStaged)
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error("taken"), { code: "EEXISTS", names: ["a.txt"] })
|
||||
)
|
||||
@@ -249,20 +254,24 @@ describe("FileExplorer", () => {
|
||||
const ask = await screen.findByTestId("fx-conflict");
|
||||
await user.click(within(ask).getByRole("button", { name: "Keep both" }));
|
||||
await waitFor(() =>
|
||||
expect(api.files.upload).toHaveBeenLastCalledWith(
|
||||
expect(api.files.uploadStaged).toHaveBeenLastCalledWith(
|
||||
1,
|
||||
"a.txt",
|
||||
file,
|
||||
"keep",
|
||||
expect.any(Function),
|
||||
expect.any(AbortSignal)
|
||||
expect.objectContaining({ upload: expect.any(String), signal: expect.any(AbortSignal) })
|
||||
)
|
||||
);
|
||||
// The retry names the SAME upload, so the staged bytes are not sent again,
|
||||
// and the staged upload is dropped once answered.
|
||||
const [first, second] = vi.mocked(api.files.uploadStaged).mock.calls.map((c) => c[4].upload);
|
||||
expect(second).toBe(first);
|
||||
await waitFor(() => expect(api.files.dropUpload).toHaveBeenCalledWith(1, first));
|
||||
});
|
||||
|
||||
it("sends many small files as one batch, asking once about every taken name", async () => {
|
||||
vi.mocked(api.files.uploadCheck).mockResolvedValue({ taken: ["a.txt", "b.txt"], batch: true });
|
||||
vi.mocked(api.files.uploadBatch).mockResolvedValue({ saved: 3, skipped: 0, paths: [] });
|
||||
vi.mocked(api.files.uploadBatchStaged).mockResolvedValue({ saved: 3, skipped: 0, paths: [] });
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
@@ -271,33 +280,32 @@ describe("FileExplorer", () => {
|
||||
const ask = await screen.findByTestId("fx-conflict");
|
||||
await user.click(within(ask).getByRole("button", { name: "Replace" }));
|
||||
await waitFor(() =>
|
||||
expect(api.files.uploadBatch).toHaveBeenCalledWith(
|
||||
expect(api.files.uploadBatchStaged).toHaveBeenCalledWith(
|
||||
1,
|
||||
"",
|
||||
files.map((file) => ({ file, rel: file.name })),
|
||||
"replace",
|
||||
expect.any(Function),
|
||||
expect.any(AbortSignal)
|
||||
expect.objectContaining({ upload: expect.any(String), signal: expect.any(AbortSignal) })
|
||||
)
|
||||
);
|
||||
expect(api.files.uploadCheck).toHaveBeenCalledWith(1, "", ["a.txt", "b.txt", "c.txt"]);
|
||||
expect(api.files.upload).not.toHaveBeenCalled();
|
||||
expect(api.files.uploadStaged).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to one request per file when the server cannot take a batch", async () => {
|
||||
vi.mocked(api.files.uploadCheck).mockResolvedValue({ taken: [], batch: false });
|
||||
vi.mocked(api.files.upload).mockResolvedValue({ path: "x" });
|
||||
vi.mocked(api.files.uploadStaged).mockResolvedValue({ path: "x" });
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const files = ["x.txt", "y.txt"].map((n) => new File(["hi"], n));
|
||||
await user.upload(screen.getByTestId("fx-upload-files"), files);
|
||||
await waitFor(() => expect(api.files.upload).toHaveBeenCalledTimes(2));
|
||||
expect(api.files.uploadBatch).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(api.files.uploadStaged).toHaveBeenCalledTimes(2));
|
||||
expect(api.files.uploadBatchStaged).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves a picked repo's .git out of a folder upload, with one notice", async () => {
|
||||
vi.mocked(api.files.upload).mockResolvedValue({ path: "x" });
|
||||
vi.mocked(api.files.uploadStaged).mockResolvedValue({ path: "x" });
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const mk = (rel: string) => {
|
||||
@@ -307,8 +315,8 @@ describe("FileExplorer", () => {
|
||||
};
|
||||
const files = ["repo/.git/HEAD", "repo/.git/objects/d4/ab", "repo/src/a.js"].map(mk);
|
||||
fireEvent.change(screen.getByTestId("fx-upload-folder"), { target: { files } });
|
||||
await waitFor(() => expect(api.files.upload).toHaveBeenCalledTimes(1));
|
||||
expect(vi.mocked(api.files.upload).mock.calls[0]![1]).toBe("repo/src/a.js");
|
||||
await waitFor(() => expect(api.files.uploadStaged).toHaveBeenCalledTimes(1));
|
||||
expect(vi.mocked(api.files.uploadStaged).mock.calls[0]![1]).toBe("repo/src/a.js");
|
||||
expect(toast.info).toHaveBeenCalledTimes(1);
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -320,7 +328,7 @@ describe("FileExplorer", () => {
|
||||
Object.defineProperty(big, "size", { value: 600 * 1024 * 1024 });
|
||||
fireEvent.change(screen.getByTestId("fx-upload-files"), { target: { files: [big] } });
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
expect(api.files.upload).not.toHaveBeenCalled();
|
||||
expect(api.files.uploadStaged).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores from the Trash", async () => {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @file UrlPreview.test.tsx
|
||||
* @description The Preview tab: port chips before a page is open, the
|
||||
* address bar opening a page in a sandboxed frame without top navigation,
|
||||
* a link's request opening its page, the dashboard's own origin refused, and
|
||||
* the framing-refusal strip shown for an outside site only.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import "../../../i18n";
|
||||
import { UrlPreview } from "../UrlPreview";
|
||||
|
||||
beforeEach(() => window.localStorage.clear());
|
||||
|
||||
describe("UrlPreview", () => {
|
||||
it("offers the workspace's ports, and a chip opens that page", () => {
|
||||
render(<UrlPreview laneId={1} ports={{ web: 3001 }} request={null} />);
|
||||
fireEvent.click(screen.getByText("localhost:3001"));
|
||||
const frame = screen.getByTestId("url-preview-frame");
|
||||
expect(frame.getAttribute("src")).toBe("http://localhost:3001/");
|
||||
expect(frame.getAttribute("sandbox")).not.toMatch(/top-navigation/);
|
||||
expect(window.localStorage.getItem("agenthub.preview.url.1")).toBe("http://localhost:3001/");
|
||||
});
|
||||
|
||||
it("opens what the address bar names, and refuses the dashboard itself", () => {
|
||||
render(<UrlPreview laneId={2} ports={{}} request={null} />);
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.change(input, { target: { value: window.location.origin + "/settings" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(screen.queryByTestId("url-preview-frame")).toBeNull();
|
||||
fireEvent.change(input, { target: { value: "127.0.0.1:8080/app" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(screen.getByTestId("url-preview-frame").getAttribute("src")).toBe(
|
||||
"http://127.0.0.1:8080/app"
|
||||
);
|
||||
});
|
||||
|
||||
it("warns that an outside site may refuse to be framed, never for a local page", () => {
|
||||
render(<UrlPreview laneId={4} ports={{}} request={null} />);
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.change(input, { target: { value: "localhost:3000" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(screen.queryByTestId("url-preview-blocked-hint")).toBeNull();
|
||||
fireEvent.change(input, { target: { value: "https://youtube.com" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(screen.getByTestId("url-preview-blocked-hint")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens a link's request", () => {
|
||||
render(
|
||||
<UrlPreview laneId={3} ports={{}} request={{ url: "http://localhost:5173/", nonce: 1 }} />
|
||||
);
|
||||
expect(screen.getByTestId("url-preview-frame").getAttribute("src")).toBe(
|
||||
"http://localhost:5173/"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -858,7 +858,7 @@ export const PaneChat = memo(function PaneChat({
|
||||
)}
|
||||
</div>
|
||||
<div className="mx-4">
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
<div className="mx-auto w-full max-w-5xl">
|
||||
{menu && (
|
||||
<PaneMenuCard
|
||||
// A new question starts with its field closed.
|
||||
|
||||
@@ -86,10 +86,12 @@ import {
|
||||
sameFilesHere,
|
||||
} from "../../lib/promptAttach";
|
||||
import { attachImeInput } from "../../lib/imeInput";
|
||||
import { SCROLL_SPEED, wheelReports, type WheelState } from "../../lib/wheelReports";
|
||||
import { nativePasteKey, nativePasteWorks, readNativePaste } from "../../lib/nativePaste";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
|
||||
import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize";
|
||||
import { openInPreview } from "../../lib/openInPreview";
|
||||
|
||||
type DropState = "hint" | "paste" | "uploading" | "failed" | "tooBig" | "tooBigLocalPaste";
|
||||
|
||||
@@ -453,6 +455,8 @@ export const TerminalView = memo(function TerminalView({
|
||||
fontWeightBold: 700,
|
||||
theme: paletteRef.current,
|
||||
screenReaderMode: readFlag(SCREEN_READER_KEY),
|
||||
// xterm's own scrollback scrolls at the same share as the reports below.
|
||||
scrollSensitivity: SCROLL_SPEED,
|
||||
});
|
||||
termRef.current = term;
|
||||
const fit = new FitAddon();
|
||||
@@ -462,7 +466,10 @@ export const TerminalView = memo(function TerminalView({
|
||||
searchRef.current = search;
|
||||
term.loadAddon(new ClipboardWithProvider(undefined, clipboardProvider));
|
||||
term.loadAddon(
|
||||
new WebLinksAddon((_e, uri) => {
|
||||
new WebLinksAddon((e, uri) => {
|
||||
// A local page opens in the session panel's Preview (one rule
|
||||
// for every link, `lib/openInPreview.ts`); Ctrl/Cmd keeps a tab.
|
||||
if (!e.ctrlKey && !e.metaKey && !e.shiftKey && openInPreview(uri)) return;
|
||||
window.open(uri, "_blank", "noopener,noreferrer");
|
||||
})
|
||||
);
|
||||
@@ -520,9 +527,38 @@ export const TerminalView = memo(function TerminalView({
|
||||
// wheel report itself — psmux scrolls, or hands it to the pane's app.
|
||||
// ponytail: at 1;1, which is all psmux reports to the app anyway.
|
||||
let ownWheel = false;
|
||||
// Tracked: the reports are this pane's own too — one per
|
||||
// LINES_PER_REPORT rows of travel (lib/wheelReports.ts), where xterm
|
||||
// sends one per event and damps a touchpad ×0.3. SGR, as tmux always
|
||||
// asks for it; at the pointer's cell, with xterm's modifier bits.
|
||||
const wheelState: WheelState = { partial: 0 };
|
||||
const sendWheel = (ev: WheelEvent) => {
|
||||
const screen = term.element?.querySelector(".xterm-screen");
|
||||
const box = screen?.getBoundingClientRect();
|
||||
if (!box || !box.height || !box.width) return;
|
||||
const n = wheelReports(wheelState, ev, box.height / term.rows, term.rows);
|
||||
const ws = wsRef.current;
|
||||
if (!n || !ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
const clamp = (v: number, max: number) => Math.min(max, Math.max(1, v));
|
||||
const col = clamp(
|
||||
Math.floor(((ev.clientX - box.left) / box.width) * term.cols) + 1,
|
||||
term.cols
|
||||
);
|
||||
const row = clamp(
|
||||
Math.floor(((ev.clientY - box.top) / box.height) * term.rows) + 1,
|
||||
term.rows
|
||||
);
|
||||
const button = (n < 0 ? 64 : 65) | (ev.altKey ? 8 : 0) | (ev.ctrlKey ? 16 : 0);
|
||||
ws.send(`\x1b[<${button};${col};${row}M`.repeat(Math.abs(n)));
|
||||
};
|
||||
term.attachCustomWheelEventHandler((ev) => {
|
||||
const mode = term.modes.mouseTrackingMode;
|
||||
const tracked = mode !== "none" && mode !== "x10";
|
||||
// Shift+wheel stays xterm's (it reports nothing for it).
|
||||
if (tracked && !ev.shiftKey) {
|
||||
sendWheel(ev);
|
||||
return false;
|
||||
}
|
||||
if (!tracked && ownWheel) {
|
||||
if (ev.deltaY && wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(`\x1b[<${ev.deltaY < 0 ? 64 : 65};1;1M`);
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { TerminalView, altArrowSeq, loadXterm } from "../TerminalView";
|
||||
import { pasteToLane, pasteToRun } from "../../../lib/panePaste";
|
||||
import { __resetNativePaste, nativePasteWorks } from "../../../lib/nativePaste";
|
||||
@@ -326,13 +327,36 @@ describe("TerminalView", () => {
|
||||
expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(false);
|
||||
});
|
||||
|
||||
it("lets the wheel through once the pane's program tracks the mouse", () => {
|
||||
// tmux with `mouse on` sets this; the wheel must reach it as a real SGR
|
||||
// report, so returning false here would kill scrolling entirely.
|
||||
/** Gives the mocked terminal an on-screen box: 10px per column and per row. */
|
||||
const screenBox = () => {
|
||||
const term = vi.mocked(Terminal).mock.results[vi.mocked(Terminal).mock.results.length - 1]!
|
||||
.value as { element: HTMLElement };
|
||||
const screen = document.createElement("div");
|
||||
screen.className = "xterm-screen";
|
||||
screen.getBoundingClientRect = () =>
|
||||
({ left: 0, top: 0, width: termState.cols * 10, height: termState.rows * 10 }) as DOMRect;
|
||||
term.element.appendChild(screen);
|
||||
};
|
||||
|
||||
it("sends a tracked wheel itself, one report per LINES_PER_REPORT rows, at the pointer", () => {
|
||||
// tmux with `mouse on` sets this. xterm would send ONE report per event and
|
||||
// damp a touchpad ×0.3 — a swipe crawled — so the pane sends its own.
|
||||
termState.modes.mouseTrackingMode = "any";
|
||||
termState.buffer.active.type = "alternate";
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(true);
|
||||
screenBox();
|
||||
const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1]!;
|
||||
ws.sent.length = 0;
|
||||
const wheel = wheelHandlerMock.mock.calls[0]![0]!;
|
||||
// A notch of 6 rows: one report, at the cell under the pointer.
|
||||
expect(wheel(new WheelEvent("wheel", { deltaY: -60, clientX: 45, clientY: 72 }))).toBe(false);
|
||||
expect(ws.sent).toEqual(["\x1b[<64;5;8M"]);
|
||||
// A touchpad: 6 steps of 25px = 15 rows, ×0.7 scroll speed = 10.5 rows =
|
||||
// two reports, sent as they add up.
|
||||
ws.sent.length = 0;
|
||||
for (let i = 0; i < 6; i++)
|
||||
wheel(new WheelEvent("wheel", { deltaY: 25, clientX: 5, clientY: 5 }));
|
||||
expect(ws.sent.join("")).toBe("\x1b[<65;1;1M".repeat(2));
|
||||
});
|
||||
|
||||
it("lets the wheel through on a normal buffer, where xterm scrolls scrollback", () => {
|
||||
@@ -361,10 +385,11 @@ describe("TerminalView", () => {
|
||||
expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(false);
|
||||
expect(wheel(new WheelEvent("wheel", { deltaY: 100 }))).toBe(false);
|
||||
expect(ws.sent).toEqual(["\x1b[<64;1;1M", "\x1b[<65;1;1M"]);
|
||||
// Tracked again (a tmux that does report): xterm sends it, never twice.
|
||||
// Tracked again (a tmux that does report): sent once, by the tracked path.
|
||||
termState.modes.mouseTrackingMode = "any";
|
||||
expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(true);
|
||||
expect(ws.sent).toHaveLength(2);
|
||||
screenBox();
|
||||
expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(false);
|
||||
expect(ws.sent).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("keeps Shift+Tab in the terminal instead of letting focus escape", () => {
|
||||
@@ -501,9 +526,7 @@ describe("TerminalView", () => {
|
||||
// wrong. The WebGL renderer draws each cell at a fixed position instead
|
||||
// (xterm 6 removed the canvas one).
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
expect(loadAddonMock.mock.calls.some(([a]) => (a as { __webgl?: boolean }).__webgl)).toBe(
|
||||
true
|
||||
);
|
||||
expect(loadAddonMock.mock.calls.some(([a]) => (a as { __webgl?: boolean }).__webgl)).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to the DOM renderer when the browser takes the WebGL context back", () => {
|
||||
|
||||
@@ -19,15 +19,33 @@
|
||||
* the next one below) — and, for
|
||||
* a user with their own Linux account (S3), their own HTTPS
|
||||
* logins. The host account's HTTPS logins are the shared Git credentials
|
||||
* table above it, so this lists them only for a member. The private key and
|
||||
* table above it, so this lists them only for a member. **Commit signing**
|
||||
* (under the author) is PER WORKSPACE: one picker per workspace of the user's
|
||||
* (`PUT /api/me/git/signing/lane`, "Don't sign" by default), the keys that can
|
||||
* sign with Delete, a way out of the old account-wide setting and of a
|
||||
* signing setup the user made themselves, and **Import a key you have** takes
|
||||
* a private key file or paste (`POST /api/me/git/ssh-key/import`). The private key and
|
||||
* every token are write-only: the server never returns them. A host identity
|
||||
* seen by a non-admin is read-only.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Cloud, Copy, GitBranch, KeyRound, Plus, RefreshCw, Server, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Cloud,
|
||||
Copy,
|
||||
FileKey,
|
||||
GitBranch,
|
||||
KeyRound,
|
||||
PenLine,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { api, type GitIdentity } from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { toast } from "../../lib/toast";
|
||||
@@ -216,6 +234,339 @@ function GitHostForm({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit signing, PER WORKSPACE: one picker per workspace of the user's
|
||||
* (`PUT /api/me/git/signing/lane` — "Don't sign" by default, so only the
|
||||
* workspaces that need it sign), the keys that can sign, each with Delete,
|
||||
* and Import a key the user already has. The old account-wide setting
|
||||
* (every repo signed) only shows when it is on, with a button that turns it
|
||||
* off; a signing setup the user made themselves in `~/.gitconfig` can be
|
||||
* taken out (their key file stays).
|
||||
*/
|
||||
function SigningCard({
|
||||
id,
|
||||
keys,
|
||||
keyLabel,
|
||||
busy,
|
||||
run,
|
||||
}: {
|
||||
id: GitIdentity;
|
||||
keys: Key[];
|
||||
keyLabel: (keyId: string | null | undefined) => string;
|
||||
busy: boolean;
|
||||
run: (fn: () => Promise<GitIdentity>, done?: string) => Promise<boolean>;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const auth = useAuth().state;
|
||||
const me = auth.mode === "server" ? auth.user : null;
|
||||
const signing = id.signing ?? { state: "off" as const, key: null, path: null };
|
||||
const laneSigning = id.laneSigning ?? [];
|
||||
const [form, setForm] = useState<{ text: string; name: string } | null>(null);
|
||||
const [lanes, setLanes] = useState<Lane[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
api.lanes
|
||||
.list()
|
||||
.then((r) => live && setLanes(r.lanes))
|
||||
.catch(() => live && setLanes([]));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, []);
|
||||
// The user's OWN workspaces: an admin sees everyone's, but signs only theirs.
|
||||
const mine = (lanes ?? []).filter((l) => !me || l.owner_id == null || l.owner_id === me.id);
|
||||
const laneName = (l: Lane) => l.title || l.cwd;
|
||||
// Still signing, but its workspace is gone from the list (removed): the
|
||||
// folder keeps signing until it is turned off here.
|
||||
const orphans =
|
||||
lanes === null
|
||||
? []
|
||||
: laneSigning
|
||||
.filter((x) => !lanes.some((l) => l.id === x.laneId))
|
||||
.map(
|
||||
(x) =>
|
||||
({
|
||||
id: x.laneId,
|
||||
title: t("gitIdentity.signLaneGone", { id: x.laneId }),
|
||||
cwd: "",
|
||||
}) as Lane
|
||||
);
|
||||
const signedWith = (laneId: number) => laneSigning.find((x) => x.laneId === laneId);
|
||||
|
||||
const chooseLane = async (lane: Lane, keyId: string | null) =>
|
||||
run(
|
||||
() => api.gitIdentity.setLaneSigning(lane.id, keyId),
|
||||
keyId
|
||||
? t("gitIdentity.signLaneSaved", { name: laneName(lane), key: keyLabel(keyId) })
|
||||
: t("gitIdentity.signLaneStopped", { name: laneName(lane) })
|
||||
);
|
||||
|
||||
const clearForeign = async () => {
|
||||
if (
|
||||
!(await confirmDialog({
|
||||
title: t("gitIdentity.signForeignConfirm", { path: signing.path }),
|
||||
confirmLabel: t("gitIdentity.signForeignRemove"),
|
||||
}))
|
||||
)
|
||||
return;
|
||||
await run(
|
||||
() => api.gitIdentity.setSigning(null, false, true),
|
||||
t("gitIdentity.signForeignRemoved")
|
||||
);
|
||||
};
|
||||
|
||||
const deleteKey = async (k: Key) => {
|
||||
if (
|
||||
!(await confirmDialog({
|
||||
title: t("gitIdentity.signKeyDeleteConfirm", { key: keyLabel(k.id) }),
|
||||
confirmLabel: t("gitIdentity.remove"),
|
||||
}))
|
||||
)
|
||||
return;
|
||||
await run(
|
||||
() => api.gitIdentity.removeKeyById(k.id!),
|
||||
t("gitIdentity.signKeyDeleted", { key: keyLabel(k.id) })
|
||||
);
|
||||
};
|
||||
|
||||
const doImport = async () => {
|
||||
if (!form) return;
|
||||
const ok = await run(
|
||||
() =>
|
||||
api.gitIdentity.importKey({
|
||||
privateKey: form.text,
|
||||
...(form.name.trim() ? { name: form.name.trim() } : {}),
|
||||
}),
|
||||
t("gitIdentity.imported", { name: form.name.trim() || "signing" })
|
||||
);
|
||||
if (ok) setForm(null);
|
||||
};
|
||||
|
||||
const readFile = async (file: File | undefined) => {
|
||||
if (!file || !form) return;
|
||||
// A key file is a few KB; anything far larger is not one.
|
||||
if (file.size > 32 * 1024) return toast.error(t("gitIdentity.importPaste"));
|
||||
const text = await file.text();
|
||||
setForm({ ...form, text });
|
||||
};
|
||||
|
||||
const usable = keys.filter((k) => k.id);
|
||||
|
||||
return (
|
||||
<section
|
||||
className="space-y-2 rounded-lg border border-border p-3"
|
||||
aria-label={t("gitIdentity.signTitle")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<PenLine className="h-4 w-4 text-fg-muted" aria-hidden />
|
||||
<h3 className="flex-1 text-xs font-semibold text-fg-primary">
|
||||
{t("gitIdentity.signTitle")}
|
||||
</h3>
|
||||
{id.writable && !form && (
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
disabled={busy}
|
||||
onClick={() => setForm({ text: "", name: "" })}
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.importKey")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-fg-muted">{t("gitIdentity.signIntro")}</p>
|
||||
|
||||
{signing.state === "on" && (
|
||||
<div
|
||||
data-testid="git-signing-legacy"
|
||||
className="flex flex-wrap items-center gap-2 rounded border border-status-warning/40 bg-status-warning/10 px-2.5 py-1.5"
|
||||
>
|
||||
<span className="min-w-0 flex-1 text-[11px] text-fg-primary">
|
||||
{t("gitIdentity.signLegacy", { key: keyLabel(signing.key) })}
|
||||
</span>
|
||||
{id.writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void run(() => api.gitIdentity.setSigning(null), t("gitIdentity.signLegacyStopped"))
|
||||
}
|
||||
>
|
||||
{t("gitIdentity.signLegacyOff")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{signing.state === "foreign" && (
|
||||
<div
|
||||
data-testid="git-signing-foreign"
|
||||
className="flex flex-wrap items-center gap-2 rounded border border-border px-2.5 py-1.5"
|
||||
>
|
||||
<span className="min-w-0 flex-1 text-[11px] text-fg-secondary [overflow-wrap:anywhere]">
|
||||
{t("gitIdentity.signForeign", { path: signing.path })}
|
||||
</span>
|
||||
{id.writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN} hover:text-status-danger`}
|
||||
disabled={busy}
|
||||
onClick={() => void clearForeign()}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.signForeignRemove")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1" data-testid="git-signing-lanes">
|
||||
<div className="text-[11px] font-medium text-fg-secondary">
|
||||
{t("gitIdentity.signLanes")}
|
||||
</div>
|
||||
{usable.length === 0 ? (
|
||||
<p className="text-[11px] text-fg-muted">{t("gitIdentity.signNeedKey")}</p>
|
||||
) : lanes === null ? null : mine.length + orphans.length === 0 ? (
|
||||
<p className="text-[11px] text-fg-muted">{t("gitIdentity.signLanesEmpty")}</p>
|
||||
) : (
|
||||
[...mine, ...orphans].map((l) => {
|
||||
const on = signedWith(l.id);
|
||||
return (
|
||||
<div key={l.id} className="flex items-center gap-2">
|
||||
<span
|
||||
className={`min-w-0 flex-1 truncate text-xs ${
|
||||
on ? "text-status-success" : "text-fg-primary"
|
||||
}`}
|
||||
title={l.cwd || undefined}
|
||||
>
|
||||
{laneName(l)}
|
||||
</span>
|
||||
<select
|
||||
aria-label={t("gitIdentity.signLaneKey", { name: laneName(l) })}
|
||||
value={on?.key ?? ""}
|
||||
disabled={busy || !id.writable}
|
||||
onChange={(e) => void chooseLane(l, e.target.value || null)}
|
||||
className="select !w-48 !py-1 !text-xs"
|
||||
>
|
||||
<option value="">{t("gitIdentity.signNone")}</option>
|
||||
{usable.map((k) => (
|
||||
<option key={k.id} value={k.id}>
|
||||
{keyLabel(k.id)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{usable.length > 0 && (
|
||||
<div className="space-y-1" data-testid="git-signing-keys">
|
||||
<div className="text-[11px] font-medium text-fg-secondary">
|
||||
{t("gitIdentity.signKeys")}
|
||||
</div>
|
||||
{usable.map((k) => {
|
||||
const used = laneSigning.filter((x) => x.key === k.id).length;
|
||||
return (
|
||||
<div
|
||||
key={k.id}
|
||||
className="flex items-center gap-2 rounded border border-border px-2 py-1"
|
||||
>
|
||||
<FileKey className="h-3.5 w-3.5 shrink-0 text-fg-muted" aria-hidden />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs text-fg-primary">{keyLabel(k.id)}</div>
|
||||
<div className="truncate font-mono text-[10px] text-fg-muted">
|
||||
{k.fingerprint}
|
||||
{used > 0 ? ` · ${t("gitIdentity.signKeyUsed", { count: used })}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
onClick={() => void copyText(k.publicKey)}
|
||||
aria-label={t("gitIdentity.signKeyCopy", { key: keyLabel(k.id) })}
|
||||
title={t("gitIdentity.signHostStep")}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{id.writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN} hover:text-status-danger`}
|
||||
disabled={busy}
|
||||
aria-label={t("gitIdentity.removeKeyNamed", { name: keyLabel(k.id) })}
|
||||
onClick={() => void deleteKey(k)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.remove")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<p className="text-[10px] text-fg-muted">{t("gitIdentity.signHostStep")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form && (
|
||||
<form
|
||||
className="space-y-2 rounded border border-border p-2.5"
|
||||
aria-label={t("gitIdentity.importTitle")}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void doImport();
|
||||
}}
|
||||
>
|
||||
<div className="text-xs font-medium text-fg-primary">{t("gitIdentity.importTitle")}</div>
|
||||
<label className={`${BTN} cursor-pointer`}>
|
||||
<FileKey className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.importFile")}
|
||||
<input
|
||||
type="file"
|
||||
className="sr-only"
|
||||
onChange={(e) => void readFile(e.target.files?.[0])}
|
||||
/>
|
||||
</label>
|
||||
<textarea
|
||||
aria-label={t("gitIdentity.importPaste")}
|
||||
placeholder={t("gitIdentity.importPaste")}
|
||||
value={form.text}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
rows={4}
|
||||
onChange={(e) => setForm({ ...form, text: e.target.value })}
|
||||
className={`${INPUT} resize-y font-mono text-[10px]`}
|
||||
/>
|
||||
<input
|
||||
aria-label={t("gitIdentity.importName")}
|
||||
placeholder={t("gitIdentity.importName")}
|
||||
value={form.name}
|
||||
autoComplete="off"
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value.toLowerCase() })}
|
||||
className={`${INPUT} !w-40 font-mono`}
|
||||
/>
|
||||
<p className="text-[10px] text-fg-muted">{t("gitIdentity.importNote")}</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !form.text.trim()}
|
||||
className="rounded border border-accent/40 bg-accent/15 px-3 py-1 text-xs text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{t("gitIdentity.importSubmit")}
|
||||
</button>
|
||||
<button type="button" onClick={() => setForm(null)} className={BTN}>
|
||||
{t("gitCreds.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function GitIdentitySettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [id, setId] = useState<GitIdentity | null>(null);
|
||||
@@ -572,6 +923,10 @@ export function GitIdentitySettings() {
|
||||
)}
|
||||
</form>
|
||||
|
||||
{id.signing !== undefined && (
|
||||
<SigningCard id={id} keys={keys} keyLabel={keyLabel} busy={busy} run={run} />
|
||||
)}
|
||||
|
||||
<div className="space-y-3 rounded-lg border border-border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound className="h-4 w-4 text-fg-muted" aria-hidden />
|
||||
@@ -616,6 +971,11 @@ export function GitIdentitySettings() {
|
||||
onRemove={() => removeNamed(k.id!)}
|
||||
>
|
||||
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
|
||||
{id.signing?.state === "on" && id.signing.key === k.id && (
|
||||
<div className="text-[10px] text-status-success">
|
||||
{t("gitIdentity.signingBadge")}
|
||||
</div>
|
||||
)}
|
||||
<Step n={3}>
|
||||
{t("gitIdentity.namedGitStepHost", { name: k.name })}
|
||||
{loginRows(rows, (r) => `ssh://${r.alias}/ → ${r.user}@${r.host}`)}
|
||||
|
||||
@@ -7,7 +7,11 @@
|
||||
* other host under "Other SSH hosts" — each kind of host in its own tab, so
|
||||
* the sections never mix; "Add another key" makes a named key as one more
|
||||
* card in its own tab, a second AWS account is saved from that card under
|
||||
* the key's name with that key, and a second GitHub key is tied to its host.
|
||||
* the key's name with that key, and a second GitHub key is tied to its host;
|
||||
* commit signing PER WORKSPACE: an imported key signs nothing by itself, one
|
||||
* workspace's picker signs that workspace only, a signing key is deleted from
|
||||
* the card after a confirm, and the old account-wide setting and a setup the
|
||||
* user made themselves each have a way out.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
@@ -31,6 +35,18 @@ vi.mock("../../../lib/api", () => ({
|
||||
saveHttps: vi.fn(),
|
||||
saveAuthor: vi.fn(),
|
||||
removeHttps: vi.fn(),
|
||||
importKey: vi.fn(),
|
||||
setSigning: vi.fn(),
|
||||
setLaneSigning: vi.fn(),
|
||||
},
|
||||
lanes: {
|
||||
list: vi.fn(async () => ({
|
||||
lanes: [
|
||||
{ id: 1, title: "shop-api", cwd: "/w/shop-api" },
|
||||
{ id: 2, title: "", cwd: "/w/docs" },
|
||||
],
|
||||
counts: {},
|
||||
})),
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -38,6 +54,7 @@ vi.mock("../../../lib/toast", () => ({ toast: { success: vi.fn(), error: vi.fn()
|
||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(() => Promise.resolve(true)) }));
|
||||
vi.mock("../../../lib/clipboard", () => ({ copyText: vi.fn(() => Promise.resolve(true)) }));
|
||||
|
||||
const OFF = { state: "off" as const, key: null, path: null };
|
||||
const KEY = { publicKey: "ssh-ed25519 AAAAC3Nz an@agenthub", fingerprint: "SHA256:abc" };
|
||||
const view = (over: Partial<GitIdentity> = {}): GitIdentity => ({
|
||||
account: "member",
|
||||
@@ -335,4 +352,130 @@ describe("GitIdentitySettings", () => {
|
||||
});
|
||||
expect(await within(work).findByText("ssh://git-2/ → git@github.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("imports a key without signing anything by itself", async () => {
|
||||
const signingKey = {
|
||||
...KEY,
|
||||
id: "id_ed25519_signing",
|
||||
type: "ed25519" as const,
|
||||
name: "signing",
|
||||
};
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(
|
||||
view({ sshKeys: [], signing: { state: "off", key: null, path: null }, laneSigning: [] })
|
||||
);
|
||||
vi.mocked(api.gitIdentity.importKey).mockResolvedValue({
|
||||
...view({ sshKeys: [signingKey], signing: OFF, laneSigning: [] }),
|
||||
imported: signingKey,
|
||||
signError: null,
|
||||
});
|
||||
render(<GitIdentitySettings />);
|
||||
expect(await screen.findByText(/Import a key you have, or make one/)).toBeInTheDocument();
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Import a key you have" }));
|
||||
const form = screen.getByRole("form", { name: "Import an existing private key" });
|
||||
await userEvent.type(
|
||||
within(form).getByLabelText(/paste the private key/),
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----x"
|
||||
);
|
||||
await userEvent.click(within(form).getByRole("button", { name: "Import" }));
|
||||
expect(api.gitIdentity.importKey).toHaveBeenCalledWith({
|
||||
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----x",
|
||||
});
|
||||
// Now there is a key: each workspace gets its own picker, all "Don't sign".
|
||||
expect(
|
||||
await screen.findByLabelText("Signing key for shop-api", {}, { timeout: 5000 })
|
||||
).toHaveValue("");
|
||||
expect(screen.getByLabelText("Signing key for /w/docs")).toHaveValue("");
|
||||
expect(api.gitIdentity.setSigning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("signs one workspace only, and stops it", async () => {
|
||||
const k = { ...KEY, id: "id_ed25519_signing", type: "ed25519" as const, name: "signing" };
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(
|
||||
view({ sshKeys: [k], signing: { state: "off", key: null, path: null }, laneSigning: [] })
|
||||
);
|
||||
vi.mocked(api.gitIdentity.setLaneSigning)
|
||||
.mockResolvedValueOnce(
|
||||
view({ sshKeys: [k], signing: OFF, laneSigning: [{ laneId: 1, key: k.id }] })
|
||||
)
|
||||
.mockResolvedValueOnce(view({ sshKeys: [k], signing: OFF, laneSigning: [] }));
|
||||
render(<GitIdentitySettings />);
|
||||
const pick = await screen.findByLabelText("Signing key for shop-api", {}, { timeout: 5000 });
|
||||
await userEvent.selectOptions(pick, k.id);
|
||||
expect(api.gitIdentity.setLaneSigning).toHaveBeenCalledWith(1, k.id);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText("Signing key for shop-api")).toHaveValue(k.id)
|
||||
);
|
||||
expect(screen.getByLabelText("Signing key for /w/docs")).toHaveValue("");
|
||||
expect(
|
||||
within(screen.getByTestId("git-signing-keys")).getByText(/signs 1 workspace/)
|
||||
).toBeTruthy();
|
||||
await userEvent.selectOptions(screen.getByLabelText("Signing key for shop-api"), "");
|
||||
expect(api.gitIdentity.setLaneSigning).toHaveBeenLastCalledWith(1, null);
|
||||
expect(api.gitIdentity.setSigning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lists a removed workspace that still signs, so it can be turned off", async () => {
|
||||
const k = { ...KEY, id: "id_ed25519_signing", type: "ed25519" as const, name: "signing" };
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(
|
||||
view({ sshKeys: [k], signing: OFF, laneSigning: [{ laneId: 9, key: k.id }] })
|
||||
);
|
||||
vi.mocked(api.gitIdentity.setLaneSigning).mockResolvedValue(
|
||||
view({ sshKeys: [k], signing: OFF, laneSigning: [] })
|
||||
);
|
||||
render(<GitIdentitySettings />);
|
||||
const pick = await screen.findByLabelText(
|
||||
"Signing key for Workspace #9 (removed)",
|
||||
{},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
expect(pick).toHaveValue(k.id);
|
||||
await userEvent.selectOptions(pick, "");
|
||||
expect(api.gitIdentity.setLaneSigning).toHaveBeenCalledWith(9, null);
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByLabelText("Signing key for Workspace #9 (removed)")).toBeNull()
|
||||
);
|
||||
});
|
||||
|
||||
it("deletes a signing key from the signing card, after asking", async () => {
|
||||
const k = { ...KEY, id: "id_ed25519_signing", type: "ed25519" as const, name: "signing" };
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(
|
||||
view({
|
||||
sshKeys: [k],
|
||||
signing: { state: "off", key: null, path: null },
|
||||
laneSigning: [{ laneId: 1, key: k.id }],
|
||||
})
|
||||
);
|
||||
vi.mocked(api.gitIdentity.removeKeyById).mockResolvedValue(
|
||||
view({ sshKeys: [], signing: OFF, laneSigning: [] })
|
||||
);
|
||||
render(<GitIdentitySettings />);
|
||||
const keys = await screen.findByTestId("git-signing-keys", {}, { timeout: 5000 });
|
||||
await userEvent.click(within(keys).getByRole("button", { name: "Delete key signing" }));
|
||||
expect(confirmDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: expect.stringMatching(/every workspace signing with it/) })
|
||||
);
|
||||
expect(api.gitIdentity.removeKeyById).toHaveBeenCalledWith(k.id);
|
||||
await waitFor(() => expect(screen.queryByTestId("git-signing-keys")).toBeNull());
|
||||
});
|
||||
|
||||
it("turns off the old account-wide signing and removes a setup the user made", async () => {
|
||||
const k = { ...KEY, id: "id_ed25519", type: "ed25519" as const, name: null };
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(
|
||||
view({ sshKeys: [k], signing: { state: "on", key: "id_ed25519", path: null } })
|
||||
);
|
||||
vi.mocked(api.gitIdentity.setSigning).mockResolvedValue(
|
||||
view({ sshKeys: [k], signing: { state: "foreign", key: null, path: "~/.ssh/mine" } })
|
||||
);
|
||||
render(<GitIdentitySettings />);
|
||||
const legacy = await screen.findByTestId("git-signing-legacy");
|
||||
await userEvent.click(within(legacy).getByRole("button", { name: "Sign per workspace only" }));
|
||||
expect(api.gitIdentity.setSigning).toHaveBeenCalledWith(null);
|
||||
const foreign = await screen.findByTestId("git-signing-foreign");
|
||||
expect(foreign).toHaveTextContent("~/.ssh/mine");
|
||||
await userEvent.click(within(foreign).getByRole("button", { name: "Remove" }));
|
||||
expect(confirmDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: expect.stringMatching(/key file itself is not deleted/) })
|
||||
);
|
||||
expect(api.gitIdentity.setSigning).toHaveBeenLastCalledWith(null, false, true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @file UserAvatar.tsx
|
||||
* @description An account's round picture: the signed-in account's own
|
||||
* (`GET /api/me/avatar`) or, with `userId`, another's (`GET
|
||||
* /api/users/:id/avatar`, staff only), versioned by `avatarAt`; without one —
|
||||
* /api/users/:id/avatar`, any signed-in account), versioned by `avatarAt`; without one —
|
||||
* or when the image fails to load — the first letters of its name. Used by the
|
||||
* sidebar's account row, its workspace owner groups, Settings → Account, the
|
||||
* Users page and its editor, the pool board and member picker, and System
|
||||
@@ -23,7 +23,7 @@ export function UserAvatar({
|
||||
}: {
|
||||
name: string;
|
||||
avatarAt?: string | null;
|
||||
/** Another account's (staff only); omitted = the signed-in account's own. */
|
||||
/** Another account's; omitted = the signed-in account's own. */
|
||||
userId?: number;
|
||||
/** How many letters of the name stand in for a missing picture. */
|
||||
letters?: number;
|
||||
|
||||
@@ -182,6 +182,12 @@
|
||||
"historyNone": "No history yet — the server keeps it in memory on Linux, from the moment it starts.",
|
||||
"agenthub": "AgentHub",
|
||||
"usersTitle": "Users · CPU / RAM now",
|
||||
"usersCount": "{{n}} users"
|
||||
"usersCount": "{{n}} users",
|
||||
"userTipRole": "Role: {{v}}",
|
||||
"userTipToday": "Today: {{cost}} · {{tokens}} tokens",
|
||||
"userTipWork": "{{w}} workspaces · {{s}} sessions",
|
||||
"userTipLastLogin": "Last sign-in: {{v}}",
|
||||
"userTipNever": "never",
|
||||
"userTipDisabled": "Disabled"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,6 +262,7 @@
|
||||
"jobs.expand": "Show background tasks",
|
||||
"uploads.stop": "Stop upload",
|
||||
"uploads.stopConfirm": "Click again to stop",
|
||||
"uploads.waiting": "The dashboard is restarting — the upload resumes when it is back.",
|
||||
"uploads.stopping": "Stopping…",
|
||||
"uploads.stopped_one": "Upload stopped — {{count}} of {{total}} file sent.",
|
||||
"uploads.stopped_other": "Upload stopped — {{count}} of {{total}} files sent.",
|
||||
@@ -385,6 +386,16 @@
|
||||
"menu.remove": "Delete workspace…",
|
||||
"panel.changes": "Changes",
|
||||
"panel.analyst": "Analyst",
|
||||
"panel.preview": "Preview",
|
||||
"preview.title": "Page preview",
|
||||
"preview.address": "Page address",
|
||||
"preview.reload": "Reload",
|
||||
"preview.openTab": "Open in a browser tab",
|
||||
"preview.badUrl": "Can't open that address — only an http(s) page other than AgentHub itself.",
|
||||
"preview.empty": "Type an address, or click a localhost link in the chat or terminal.",
|
||||
"preview.loginHint": "A blank page? The site may forbid embedding or need a login — open it in a browser tab.",
|
||||
"preview.blockedHint": "Sites like YouTube, Google or a login page refuse to show inside another page. A blank page or a broken-page icon means that — open it in a browser tab.",
|
||||
"preview.openTabShort": "Open tab",
|
||||
"replay.placeholder": "Message to continue this session…",
|
||||
"replay.hint": "Sending resumes the session in a free pane.",
|
||||
"replay.send": "Send",
|
||||
@@ -635,6 +646,7 @@
|
||||
"fx.pasted": "Pasted.",
|
||||
"fx.crossOnlyWorkspace": "These items can only be pasted where they came from (a session worktree, an extra folder, or between two homes).",
|
||||
"fx.needsUpgrade": "This needs the updated server helper — ask an admin to run `agenthub server install-system` once.",
|
||||
"fx.secret": "Hidden folders at the top of a home (.ssh, .config…) are not editable here — use a terminal. For an SSH key to clone or sign commits: Settings → Git.",
|
||||
"fx.conflictTitle": "Already there",
|
||||
"fx.conflictBody": "This name is taken in the destination: {{names}}",
|
||||
"fx.conflictAll": "Do this for every remaining file",
|
||||
|
||||
@@ -77,13 +77,21 @@
|
||||
"run_done_other": "{{count}} runs finished 🐾"
|
||||
},
|
||||
"greet": {
|
||||
"morning": ["good morning! ☀️", "morning! coffee first? ☕", "rise and shine 🌤️"],
|
||||
"morning": [
|
||||
"good morning! ☀️",
|
||||
"morning! coffee first? ☕",
|
||||
"rise and shine 🌤️"
|
||||
],
|
||||
"afternoon": [
|
||||
"good afternoon! 🐾",
|
||||
"hey, welcome back 😺",
|
||||
"afternoon! ready when you are ✨"
|
||||
],
|
||||
"evening": ["good evening! 🌆", "evening shift? I'm in 😼", "hey there, night owl-to-be 🌙"],
|
||||
"evening": [
|
||||
"good evening! 🌆",
|
||||
"evening shift? I'm in 😼",
|
||||
"hey there, night owl-to-be 🌙"
|
||||
],
|
||||
"night": [
|
||||
"burning the midnight oil? 🌙",
|
||||
"late one, huh? I'll keep watch 🦉",
|
||||
@@ -106,7 +114,11 @@
|
||||
"help": "I'm {{name}} - I watch your sessions. Try \"what's running\", \"any errors\", \"what's waiting\" or \"status\". Anything else, I'll hand to Claude.",
|
||||
"whoami": "I'm {{name}}, your session-watching buddy 🐾 Right-click me to change my look, or click my name in the panel to rename me.",
|
||||
"hello": "hi there! 😺 {{summary}}.",
|
||||
"thanks": ["anytime! 💜", "happy to help 😸", "you got it 🐾"],
|
||||
"thanks": [
|
||||
"anytime! 💜",
|
||||
"happy to help 😸",
|
||||
"you got it 🐾"
|
||||
],
|
||||
"errors_one": "{{count}} session errored{{list}} - open the panel to jump to it.",
|
||||
"errors_other": "{{count}} sessions errored{{list}} - open the panel to jump to them.",
|
||||
"errorsNone": "no errors - all clean 🐾",
|
||||
@@ -163,17 +175,41 @@
|
||||
"can't reach the server 📡",
|
||||
"no signal - taking a nap 💤"
|
||||
],
|
||||
"worried": ["that didn't look right 😟", "keeping an eye out 👀", "hmm, something's off 🫣"],
|
||||
"worried": [
|
||||
"that didn't look right 😟",
|
||||
"keeping an eye out 👀",
|
||||
"hmm, something's off 🫣"
|
||||
],
|
||||
"stuck": [
|
||||
"a session's been quiet a while… 🤔",
|
||||
"is something stuck? ⏳",
|
||||
"still chewing on it… 😾"
|
||||
],
|
||||
"happy": ["great run! 😻", "love a tidy finish ✨", "purrfect 💜"],
|
||||
"thinking": ["hmm, let me look… 🤔", "sniffing around… 🐾", "one sec, checking 🔍"],
|
||||
"watching": ["on the prowl 👀", "watching your sessions 😼", "eyes peeled 🐾"],
|
||||
"sleeping": ["zzz… 💤", "wake me if something happens 😴", "curled up, all calm 🐈"],
|
||||
"idle": ["all quiet 😺", "ready when you are 🐾", "just vibing ✨"]
|
||||
"happy": [
|
||||
"great run! 😻",
|
||||
"love a tidy finish ✨",
|
||||
"purrfect 💜"
|
||||
],
|
||||
"thinking": [
|
||||
"hmm, let me look… 🤔",
|
||||
"sniffing around… 🐾",
|
||||
"one sec, checking 🔍"
|
||||
],
|
||||
"watching": [
|
||||
"on the prowl 👀",
|
||||
"watching your sessions 😼",
|
||||
"eyes peeled 🐾"
|
||||
],
|
||||
"sleeping": [
|
||||
"zzz… 💤",
|
||||
"wake me if something happens 😴",
|
||||
"curled up, all calm 🐈"
|
||||
],
|
||||
"idle": [
|
||||
"all quiet 😺",
|
||||
"ready when you are 🐾",
|
||||
"just vibing ✨"
|
||||
]
|
||||
},
|
||||
"chat": {
|
||||
"label": "Chat with {{name}}",
|
||||
@@ -191,6 +227,41 @@
|
||||
"starting": "Starting {{tool}}…",
|
||||
"notStarted": "Waiting for the session to start — if it asks something, open it in the workspace.",
|
||||
"notSent": "Couldn't reach the session's terminal — try again, or open it in the workspace."
|
||||
},
|
||||
"people": {
|
||||
"rail": "Chats",
|
||||
"new": "New chat",
|
||||
"newHint": "Pick one person for a direct chat, or several for a group.",
|
||||
"search": "Search people",
|
||||
"none": "No one found",
|
||||
"groupName": "Group name (optional)",
|
||||
"createGroup": "Create group ({{n}} people)",
|
||||
"startDm": "Start chat",
|
||||
"close": "Close",
|
||||
"unreadIn": "{{name}}, {{n}} unread",
|
||||
"roomLabel": "Chat with {{name}}",
|
||||
"members": "{{n}} members",
|
||||
"settings": "Group settings",
|
||||
"back": "Back to the chat",
|
||||
"rename": "Rename",
|
||||
"creator": "· created the group",
|
||||
"remove": "Remove {{name}}",
|
||||
"add": "Add people",
|
||||
"addN": "Add {{n}}",
|
||||
"leave": "Leave group",
|
||||
"leaveTitle": "Leave this group?",
|
||||
"leaveBody": "You will no longer see {{name}}. Someone in it can add you again.",
|
||||
"older": "Load older messages",
|
||||
"empty": "No messages yet. Say hello to {{name}}.",
|
||||
"placeholder": "Message {{name}}",
|
||||
"attach": "Attach files",
|
||||
"unattach": "Remove {{name}}",
|
||||
"tooBig": "{{name}} is over 25 MB",
|
||||
"sysCreated": "{{who}} created the group",
|
||||
"sysRenamed": "{{who}} renamed the group",
|
||||
"sysLeft": "{{who}} left",
|
||||
"sysAdded": "{{who}} added {{names}}",
|
||||
"sysRemoved": "{{who}} removed {{names}}"
|
||||
}
|
||||
},
|
||||
"pricing": {
|
||||
@@ -883,6 +954,40 @@
|
||||
"authorName": "Name",
|
||||
"authorEmail": "Email",
|
||||
"authorUnset": "Not set yet: commits from the dashboard and your agents fail until a name and email are saved. Filled in from your account — check and save.",
|
||||
"signTitle": "Commit signing",
|
||||
"signIntro": "Choose which workspaces sign their commits — from your terminal, the Changes panel or an agent — with one of your SSH keys. Every other repository stays unsigned.",
|
||||
"signLanes": "Workspaces",
|
||||
"signLanesEmpty": "You have no workspace yet.",
|
||||
"signNeedKey": "Import a key you have, or make one below, to sign with.",
|
||||
"signLaneGone": "Workspace #{{id}} (removed)",
|
||||
"signLaneKey": "Signing key for {{name}}",
|
||||
"signLaneSaved": "{{name}} now signs its commits with {{key}}.",
|
||||
"signLaneStopped": "{{name}} no longer signs its commits.",
|
||||
"signLegacy": "Every commit of your account is signed with {{key}} (the old account-wide setting).",
|
||||
"signLegacyOff": "Sign per workspace only",
|
||||
"signLegacyStopped": "Account-wide signing is off — each workspace keeps its own choice.",
|
||||
"signForeign": "Your ~/.gitconfig signs every commit with a key you set up yourself: {{path}}.",
|
||||
"signForeignRemove": "Remove",
|
||||
"signForeignConfirm": "Take {{path}} out of your ~/.gitconfig? Commits stop being signed with it; the key file itself is not deleted.",
|
||||
"signForeignRemoved": "The old signing setup was taken out of ~/.gitconfig.",
|
||||
"signKeys": "Keys you can sign with",
|
||||
"signKeyUsed_one": "signs {{count}} workspace",
|
||||
"signKeyUsed_other": "signs {{count}} workspaces",
|
||||
"signKeyCopy": "Copy the public key of {{key}}",
|
||||
"signKeyDeleteConfirm": "Delete key {{key}}? Its private file is deleted, every workspace signing with it stops signing, and clones that use it can no longer fetch or push.",
|
||||
"signKeyDeleted": "Key {{key}} deleted.",
|
||||
"signKey": "Signing key",
|
||||
"signNone": "Don't sign",
|
||||
"signHostStep": "Add this public key to your account on the host as a SIGNING key (Gitea/GitHub: SSH / GPG Keys — on Gitea press Verify), and keep your commit email above the same as the host account's.",
|
||||
"importKey": "Import a key you have",
|
||||
"importTitle": "Import an existing private key",
|
||||
"importFile": "Choose the key file",
|
||||
"importPaste": "…or paste the private key (-----BEGIN … PRIVATE KEY-----)",
|
||||
"importName": "Name (optional)",
|
||||
"importNote": "The key must have no passphrase (a dashboard commit has nowhere to type one). It is stored in your own account; only its public half is ever shown again.",
|
||||
"importSubmit": "Import",
|
||||
"imported": "Key {{name}} imported.",
|
||||
"signingBadge": "Signs commits",
|
||||
"authorSaved": "Commit author saved.",
|
||||
"title": "Your git identity",
|
||||
"descriptionMember": "Your own SSH key and HTTPS logins, kept in your own home. Add workspace → Clone uses them, and the clone keeps them for its later fetch, pull and push.",
|
||||
@@ -904,9 +1009,9 @@
|
||||
"terminalHint": "A clone, fetch or push you type yourself (in a pane or over SSH) uses this key, then your own ~/.ssh keys. Sets core.sshCommand in your ~/.gitconfig.",
|
||||
"terminalForeign": "Your ~/.gitconfig already sets its own core.sshCommand, so AgentHub leaves it alone. Remove it to use this key in the terminal.",
|
||||
"sshIntro": "Use the section for where your code lives. Each kind of host takes its own key; you need only the ones you use.",
|
||||
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket",
|
||||
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket · Backlog",
|
||||
"gitHubSub": "ED25519 key — the git@… SSH address",
|
||||
"gitHubStepAdd": "Add it to your account on the host: GitHub → Settings → SSH and GPG keys → New SSH key. GitLab: Preferences → SSH Keys. Paste the whole line.",
|
||||
"gitHubStepAdd": "Add it to your account on the host and paste the whole line: GitHub → Settings → SSH and GPG keys. GitLab → Preferences → SSH Keys. Gitea → Settings → SSH / GPG Keys. Backlog → Personal settings → Register SSH key.",
|
||||
"awsTitle": "AWS CodeCommit",
|
||||
"awsSub": "RSA 4096 key — AWS accepts only RSA",
|
||||
"stepCopy": "Copy the public key.",
|
||||
@@ -932,10 +1037,10 @@
|
||||
"aliasPlaceholder": "name (optional)",
|
||||
"aliasHint": "Leave empty for the default account of this host. Give a name (e.g. aws-prod, lowercase, no dot) for one more account: clone it as ssh://<name>/…",
|
||||
"awsMany": "Several AWS accounts? Add another key for each — it is made at once — upload it to that account, then save its SSH key ID in that key’s step 3. Clone with the URL AWS gives you as usual: AgentHub tries each saved account until one can open the repository.",
|
||||
"tabGit": "GitHub · GitLab",
|
||||
"tabGit": "Git (GitHub, GitLab, Gitea, Backlog…)",
|
||||
"tabOther": "Other hosts",
|
||||
"addKey": "Add another key",
|
||||
"namedGitSub": "ED25519 · for one more GitHub/GitLab account.",
|
||||
"namedGitSub": "ED25519 · for one more account on a git host.",
|
||||
"namedGitStepHost": "Tie it to its host, so a clone through ssh://{{name}}/… offers only this key (git@host would log in with the default key’s account):",
|
||||
"namedAwsSub": "RSA · for one more AWS account.",
|
||||
"removeKeyNamed": "Delete key {{name}}"
|
||||
|
||||
@@ -182,6 +182,12 @@
|
||||
"historyNone": "Chưa có lịch sử — server chỉ giữ trong bộ nhớ trên Linux, tính từ lúc khởi động.",
|
||||
"agenthub": "AgentHub",
|
||||
"usersTitle": "Người dùng · CPU / RAM hiện tại",
|
||||
"usersCount": "{{n}} người dùng"
|
||||
"usersCount": "{{n}} người dùng",
|
||||
"userTipRole": "Vai trò: {{v}}",
|
||||
"userTipToday": "Hôm nay: {{cost}} · {{tokens}} token",
|
||||
"userTipWork": "{{w}} workspace · {{s}} phiên",
|
||||
"userTipLastLogin": "Đăng nhập gần nhất: {{v}}",
|
||||
"userTipNever": "chưa bao giờ",
|
||||
"userTipDisabled": "Đã vô hiệu hóa"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,6 +262,7 @@
|
||||
"jobs.expand": "Xem các việc chạy nền",
|
||||
"uploads.stop": "Ngưng tải lên",
|
||||
"uploads.stopConfirm": "Bấm lần nữa để ngưng",
|
||||
"uploads.waiting": "Dashboard đang khởi động lại — tải lên sẽ tiếp tục khi có kết nối.",
|
||||
"uploads.stopping": "Đang ngưng…",
|
||||
"uploads.stopped_one": "Đã ngưng tải lên — đã gửi {{count}}/{{total}} tệp.",
|
||||
"uploads.stopped_other": "Đã ngưng tải lên — đã gửi {{count}}/{{total}} tệp.",
|
||||
@@ -385,6 +386,16 @@
|
||||
"menu.remove": "Xóa workspace…",
|
||||
"panel.changes": "Thay đổi",
|
||||
"panel.analyst": "Analyst",
|
||||
"panel.preview": "Xem trang",
|
||||
"preview.title": "Xem trước trang web",
|
||||
"preview.address": "Địa chỉ trang",
|
||||
"preview.reload": "Tải lại",
|
||||
"preview.openTab": "Mở trong tab trình duyệt",
|
||||
"preview.badUrl": "Không mở được địa chỉ này — chỉ mở được trang http(s), không phải chính AgentHub.",
|
||||
"preview.empty": "Nhập địa chỉ, hoặc bấm một link localhost trong chat hay terminal.",
|
||||
"preview.loginHint": "Trang trắng? Trang có thể chặn nhúng hoặc cần đăng nhập — hãy mở trong tab trình duyệt.",
|
||||
"preview.blockedHint": "Các trang như YouTube, Google hay trang đăng nhập không cho hiện bên trong trang khác. Thấy trang trắng hoặc biểu tượng lỗi là vì vậy — hãy mở trong tab trình duyệt.",
|
||||
"preview.openTabShort": "Mở tab",
|
||||
"replay.placeholder": "Nhắn để tiếp tục session này…",
|
||||
"replay.hint": "Gửi tin sẽ resume session trong một pane trống.",
|
||||
"replay.send": "Gửi",
|
||||
@@ -635,6 +646,7 @@
|
||||
"fx.pasted": "Đã dán.",
|
||||
"fx.crossOnlyWorkspace": "Các mục này chỉ dán được ở nơi chúng được lấy ra (worktree của session, thư mục phụ, hoặc giữa hai home).",
|
||||
"fx.needsUpgrade": "Cần cập nhật helper của server — nhờ admin chạy `agenthub server install-system` một lần.",
|
||||
"fx.secret": "Thư mục ẩn ở cấp đầu home (.ssh, .config…) không chỉnh được ở đây — hãy dùng terminal. Cần SSH key để clone hay ký commit: Settings → Git.",
|
||||
"fx.conflictTitle": "Đã có sẵn",
|
||||
"fx.conflictBody": "Tên này đã có ở nơi đến: {{names}}",
|
||||
"fx.conflictAll": "Áp dụng cho tất cả file còn lại",
|
||||
|
||||
@@ -77,10 +77,26 @@
|
||||
"run_done_other": "{{count}} lượt chạy vừa xong 🐾"
|
||||
},
|
||||
"greet": {
|
||||
"morning": ["chào buổi sáng! ☀️", "sáng rồi! cà phê chưa? ☕", "ngày mới tốt lành nha 🌤️"],
|
||||
"afternoon": ["chào buổi chiều! 🐾", "bạn quay lại rồi 😺", "chiều nay làm gì đây? ✨"],
|
||||
"evening": ["chào buổi tối! 🌆", "ca tối hả? có mình đây 😼", "tối rồi, cố lên nhé 🌙"],
|
||||
"night": ["khuya rồi mà vẫn làm à? 🌙", "để mình canh giúp cho 🦉", "nhớ nghỉ ngơi nha 💤"],
|
||||
"morning": [
|
||||
"chào buổi sáng! ☀️",
|
||||
"sáng rồi! cà phê chưa? ☕",
|
||||
"ngày mới tốt lành nha 🌤️"
|
||||
],
|
||||
"afternoon": [
|
||||
"chào buổi chiều! 🐾",
|
||||
"bạn quay lại rồi 😺",
|
||||
"chiều nay làm gì đây? ✨"
|
||||
],
|
||||
"evening": [
|
||||
"chào buổi tối! 🌆",
|
||||
"ca tối hả? có mình đây 😼",
|
||||
"tối rồi, cố lên nhé 🌙"
|
||||
],
|
||||
"night": [
|
||||
"khuya rồi mà vẫn làm à? 🌙",
|
||||
"để mình canh giúp cho 🦉",
|
||||
"nhớ nghỉ ngơi nha 💤"
|
||||
],
|
||||
"summary_one": "{{count}} phiên đang chạy",
|
||||
"summary_other": "{{count}} phiên đang chạy",
|
||||
"summaryWaiting": "{{count}} phiên chờ bạn",
|
||||
@@ -98,7 +114,11 @@
|
||||
"help": "Mình là {{name}} - mình canh các phiên của bạn. Thử hỏi \"đang chạy gì\", \"có lỗi không\", \"cái gì đang chờ\" hoặc \"tình hình\". Câu khác mình sẽ chuyển cho Claude.",
|
||||
"whoami": "Mình là {{name}}, bạn đồng hành canh phiên của bạn 🐾 Chuột phải vào mình để đổi ngoại hình, hoặc bấm vào tên trong bảng để đặt tên mới.",
|
||||
"hello": "chào bạn! 😺 {{summary}}.",
|
||||
"thanks": ["có gì đâu! 💜", "rất vui được giúp 😸", "chuyện nhỏ 🐾"],
|
||||
"thanks": [
|
||||
"có gì đâu! 💜",
|
||||
"rất vui được giúp 😸",
|
||||
"chuyện nhỏ 🐾"
|
||||
],
|
||||
"errors_one": "{{count}} phiên bị lỗi{{list}} - mở bảng để xem nhé.",
|
||||
"errors_other": "{{count}} phiên bị lỗi{{list}} - mở bảng để xem nhé.",
|
||||
"errorsNone": "không có lỗi nào - sạch sẽ 🐾",
|
||||
@@ -155,13 +175,41 @@
|
||||
"không gọi được máy chủ 📡",
|
||||
"mất sóng - đi ngủ chút 💤"
|
||||
],
|
||||
"worried": ["có vẻ không ổn 😟", "mình đang để mắt đây 👀", "hừm, có gì đó lạ lạ 🫣"],
|
||||
"stuck": ["một phiên im lặng lâu rồi… 🤔", "có gì bị kẹt không? ⏳", "vẫn đang nhai… 😾"],
|
||||
"happy": ["tuyệt vời! 😻", "kết thúc gọn ghê ✨", "hoàn hảo 💜"],
|
||||
"thinking": ["để mình xem… 🤔", "đang đánh hơi… 🐾", "chờ chút, đang kiểm tra 🔍"],
|
||||
"watching": ["đang tuần tra 👀", "đang canh các phiên của bạn 😼", "mắt mở to 🐾"],
|
||||
"sleeping": ["zzz… 💤", "có gì thì gọi mình dậy nhé 😴", "cuộn tròn, mọi thứ yên ổn 🐈"],
|
||||
"idle": ["yên ắng quá 😺", "sẵn sàng khi bạn cần 🐾", "chill thôi ✨"]
|
||||
"worried": [
|
||||
"có vẻ không ổn 😟",
|
||||
"mình đang để mắt đây 👀",
|
||||
"hừm, có gì đó lạ lạ 🫣"
|
||||
],
|
||||
"stuck": [
|
||||
"một phiên im lặng lâu rồi… 🤔",
|
||||
"có gì bị kẹt không? ⏳",
|
||||
"vẫn đang nhai… 😾"
|
||||
],
|
||||
"happy": [
|
||||
"tuyệt vời! 😻",
|
||||
"kết thúc gọn ghê ✨",
|
||||
"hoàn hảo 💜"
|
||||
],
|
||||
"thinking": [
|
||||
"để mình xem… 🤔",
|
||||
"đang đánh hơi… 🐾",
|
||||
"chờ chút, đang kiểm tra 🔍"
|
||||
],
|
||||
"watching": [
|
||||
"đang tuần tra 👀",
|
||||
"đang canh các phiên của bạn 😼",
|
||||
"mắt mở to 🐾"
|
||||
],
|
||||
"sleeping": [
|
||||
"zzz… 💤",
|
||||
"có gì thì gọi mình dậy nhé 😴",
|
||||
"cuộn tròn, mọi thứ yên ổn 🐈"
|
||||
],
|
||||
"idle": [
|
||||
"yên ắng quá 😺",
|
||||
"sẵn sàng khi bạn cần 🐾",
|
||||
"chill thôi ✨"
|
||||
]
|
||||
},
|
||||
"chat": {
|
||||
"label": "Trò chuyện với {{name}}",
|
||||
@@ -179,6 +227,41 @@
|
||||
"starting": "Đang khởi động {{tool}}…",
|
||||
"notStarted": "Đang chờ phiên khởi động — nếu nó hỏi gì, hãy mở trong workspace.",
|
||||
"notSent": "Không gửi được tới terminal của phiên — thử lại, hoặc mở trong workspace."
|
||||
},
|
||||
"people": {
|
||||
"rail": "Trò chuyện",
|
||||
"new": "Chat mới",
|
||||
"newHint": "Chọn 1 người để chat riêng, hoặc nhiều người để tạo nhóm.",
|
||||
"search": "Tìm người",
|
||||
"none": "Không tìm thấy ai",
|
||||
"groupName": "Tên nhóm (không bắt buộc)",
|
||||
"createGroup": "Tạo nhóm ({{n}} người)",
|
||||
"startDm": "Bắt đầu chat",
|
||||
"close": "Đóng",
|
||||
"unreadIn": "{{name}}, {{n}} tin chưa đọc",
|
||||
"roomLabel": "Chat với {{name}}",
|
||||
"members": "{{n}} thành viên",
|
||||
"settings": "Cài đặt nhóm",
|
||||
"back": "Quay lại cuộc chat",
|
||||
"rename": "Đổi tên",
|
||||
"creator": "· người tạo nhóm",
|
||||
"remove": "Xoá {{name}} khỏi nhóm",
|
||||
"add": "Thêm người",
|
||||
"addN": "Thêm {{n}} người",
|
||||
"leave": "Rời nhóm",
|
||||
"leaveTitle": "Rời nhóm này?",
|
||||
"leaveBody": "Bạn sẽ không còn thấy {{name}}. Người trong nhóm có thể thêm bạn lại.",
|
||||
"older": "Xem tin cũ hơn",
|
||||
"empty": "Chưa có tin nhắn. Hãy chào {{name}}.",
|
||||
"placeholder": "Nhắn cho {{name}}",
|
||||
"attach": "Đính kèm file",
|
||||
"unattach": "Bỏ {{name}}",
|
||||
"tooBig": "{{name}} lớn hơn 25 MB",
|
||||
"sysCreated": "{{who}} đã tạo nhóm",
|
||||
"sysRenamed": "{{who}} đã đổi tên nhóm",
|
||||
"sysLeft": "{{who}} đã rời nhóm",
|
||||
"sysAdded": "{{who}} đã thêm {{names}}",
|
||||
"sysRemoved": "{{who}} đã xoá {{names}}"
|
||||
}
|
||||
},
|
||||
"pricing": {
|
||||
@@ -871,6 +954,40 @@
|
||||
"authorName": "Tên",
|
||||
"authorEmail": "Email",
|
||||
"authorUnset": "Chưa đặt: commit từ dashboard và từ agent của bạn sẽ lỗi cho tới khi lưu tên và email. Đã điền sẵn từ tài khoản — kiểm tra rồi bấm Lưu.",
|
||||
"signTitle": "Ký commit",
|
||||
"signIntro": "Chọn workspace nào ký commit — từ terminal, bảng Changes hay agent — bằng một SSH key của bạn. Các repo khác không ký.",
|
||||
"signLanes": "Workspace",
|
||||
"signLanesEmpty": "Bạn chưa có workspace nào.",
|
||||
"signNeedKey": "Hãy nhập key bạn có, hoặc tạo key ở phần dưới, để dùng ký.",
|
||||
"signLaneGone": "Workspace #{{id}} (đã xóa)",
|
||||
"signLaneKey": "Key ký cho {{name}}",
|
||||
"signLaneSaved": "{{name}} giờ ký commit bằng {{key}}.",
|
||||
"signLaneStopped": "{{name}} không còn ký commit.",
|
||||
"signLegacy": "Mọi commit của tài khoản đang được ký bằng {{key}} (cài đặt cũ cho cả tài khoản).",
|
||||
"signLegacyOff": "Chỉ ký theo workspace",
|
||||
"signLegacyStopped": "Đã tắt ký cho cả tài khoản — mỗi workspace giữ lựa chọn riêng.",
|
||||
"signForeign": "~/.gitconfig của bạn đang ký mọi commit bằng key bạn tự cài: {{path}}.",
|
||||
"signForeignRemove": "Gỡ",
|
||||
"signForeignConfirm": "Gỡ {{path}} khỏi ~/.gitconfig? Commit sẽ không còn ký bằng key này; file key không bị xóa.",
|
||||
"signForeignRemoved": "Đã gỡ cấu hình ký cũ khỏi ~/.gitconfig.",
|
||||
"signKeys": "Key dùng để ký",
|
||||
"signKeyUsed_one": "ký {{count}} workspace",
|
||||
"signKeyUsed_other": "ký {{count}} workspace",
|
||||
"signKeyCopy": "Sao chép public key của {{key}}",
|
||||
"signKeyDeleteConfirm": "Xóa key {{key}}? File khóa riêng sẽ bị xóa, các workspace đang ký bằng key này sẽ ngừng ký, và các bản clone dùng key này không fetch/push được nữa.",
|
||||
"signKeyDeleted": "Đã xóa key {{key}}.",
|
||||
"signKey": "Key dùng để ký",
|
||||
"signNone": "Không ký",
|
||||
"signHostStep": "Thêm public key này vào tài khoản trên host dưới dạng key KÝ (Gitea/GitHub: SSH / GPG Keys — trên Gitea bấm Verify), và giữ email commit ở trên trùng với email tài khoản trên host.",
|
||||
"importKey": "Nhập key có sẵn",
|
||||
"importTitle": "Nhập private key có sẵn",
|
||||
"importFile": "Chọn file key",
|
||||
"importPaste": "…hoặc dán private key (-----BEGIN … PRIVATE KEY-----)",
|
||||
"importName": "Tên (không bắt buộc)",
|
||||
"importNote": "Key không được có passphrase (commit từ dashboard không có chỗ nhập). Key được lưu trong tài khoản của chính bạn; sau khi nhập chỉ public key được hiển thị.",
|
||||
"importSubmit": "Nhập",
|
||||
"imported": "Đã nhập key {{name}}.",
|
||||
"signingBadge": "Ký commit",
|
||||
"authorSaved": "Đã lưu tác giả commit.",
|
||||
"title": "Danh tính git của bạn",
|
||||
"descriptionMember": "SSH key và đăng nhập HTTPS riêng của bạn, lưu trong home của chính bạn. Add workspace → Clone dùng chúng, và bản clone giữ lại cho các lần fetch, pull, push sau.",
|
||||
@@ -892,9 +1009,9 @@
|
||||
"terminalHint": "Lệnh clone, fetch hay push bạn tự gõ (trong pane hoặc qua SSH) sẽ dùng key này, sau đó tới các key trong ~/.ssh của bạn. Ghi core.sshCommand vào ~/.gitconfig của bạn.",
|
||||
"terminalForeign": "~/.gitconfig của bạn đã tự đặt core.sshCommand nên AgentHub không động vào. Xoá nó đi nếu muốn dùng key này trong terminal.",
|
||||
"sshIntro": "Dùng đúng mục theo nơi chứa code của bạn. Mỗi loại host dùng một key riêng; chỉ cần tạo key cho nơi bạn dùng.",
|
||||
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket",
|
||||
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket · Backlog",
|
||||
"gitHubSub": "Key ED25519 — địa chỉ SSH dạng git@…",
|
||||
"gitHubStepAdd": "Thêm vào tài khoản trên host: GitHub → Settings → SSH and GPG keys → New SSH key. GitLab: Preferences → SSH Keys. Dán nguyên dòng.",
|
||||
"gitHubStepAdd": "Thêm vào tài khoản trên host, dán nguyên dòng: GitHub → Settings → SSH and GPG keys. GitLab → Preferences → SSH Keys. Gitea → Settings → SSH / GPG Keys. Backlog → Cài đặt cá nhân → Đăng ký SSH key.",
|
||||
"awsTitle": "AWS CodeCommit",
|
||||
"awsSub": "Key RSA 4096 — AWS chỉ nhận RSA",
|
||||
"stepCopy": "Copy public key.",
|
||||
@@ -920,10 +1037,10 @@
|
||||
"aliasPlaceholder": "tên (tuỳ chọn)",
|
||||
"aliasHint": "Để trống cho tài khoản mặc định của host này. Đặt tên (vd aws-prod, chữ thường, không dấu chấm) cho thêm một tài khoản: clone bằng ssh://<tên>/…",
|
||||
"awsMany": "Nhiều tài khoản AWS? Bấm Thêm key khác cho mỗi tài khoản — key được tạo ngay — upload lên tài khoản đó, rồi lưu SSH key ID ở bước 3 của key đó. Clone bằng URL AWS đưa như bình thường: AgentHub tự thử từng tài khoản đã lưu đến khi mở được repository.",
|
||||
"tabGit": "GitHub · GitLab",
|
||||
"tabGit": "Git (GitHub, GitLab, Gitea, Backlog…)",
|
||||
"tabOther": "Host khác",
|
||||
"addKey": "Thêm key khác",
|
||||
"namedGitSub": "ED25519 · cho thêm một tài khoản GitHub/GitLab.",
|
||||
"namedGitSub": "ED25519 · cho thêm một tài khoản trên git host.",
|
||||
"namedGitStepHost": "Gắn key với host của nó, để clone qua ssh://{{name}}/… chỉ dùng đúng key này (git@host sẽ đăng nhập bằng tài khoản của key mặc định):",
|
||||
"namedAwsSub": "RSA · cho thêm một tài khoản AWS.",
|
||||
"removeKeyNamed": "Xoá key {{name}}"
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @file openInPreview.test.ts
|
||||
* @description The one link rule: which URLs the Preview takes (http(s),
|
||||
* never the dashboard's origin, `0.0.0.0` read as `localhost`), that a local
|
||||
* link goes to the session panel only when one answers, and that a
|
||||
* Ctrl/Cmd click keeps the browser's own tab.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
OPEN_IN_PREVIEW_EVENT,
|
||||
isLoopbackUrl,
|
||||
normalizePreviewUrl,
|
||||
onLinkClick,
|
||||
openInPreview,
|
||||
type OpenInPreview,
|
||||
} from "../openInPreview";
|
||||
|
||||
const SELF = "http://localhost:4820";
|
||||
|
||||
describe("normalizePreviewUrl", () => {
|
||||
it("adds http:// to a bare host:port and reads 0.0.0.0 as localhost", () => {
|
||||
expect(normalizePreviewUrl("localhost:3000", SELF)).toBe("http://localhost:3000/");
|
||||
expect(normalizePreviewUrl("http://0.0.0.0:5173/a", SELF)).toBe("http://localhost:5173/a");
|
||||
});
|
||||
it("refuses the dashboard's own origin, other schemes and credentials", () => {
|
||||
expect(normalizePreviewUrl("http://localhost:4820/settings", SELF)).toBeNull();
|
||||
expect(normalizePreviewUrl("javascript:alert(1)", SELF)).toBeNull();
|
||||
expect(normalizePreviewUrl("file:///etc/passwd", SELF)).toBeNull();
|
||||
expect(normalizePreviewUrl("http://u:p@localhost:3000", SELF)).toBeNull();
|
||||
expect(normalizePreviewUrl(" ", SELF)).toBeNull();
|
||||
});
|
||||
it("accepts an external https page", () => {
|
||||
expect(normalizePreviewUrl("https://example.com/x", SELF)).toBe("https://example.com/x");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLoopbackUrl", () => {
|
||||
it("is true only for this machine's hosts", () => {
|
||||
expect(isLoopbackUrl("http://localhost:3000")).toBe(true);
|
||||
expect(isLoopbackUrl("http://127.0.0.1:8080/x")).toBe(true);
|
||||
expect(isLoopbackUrl("http://[::1]:3000")).toBe(true);
|
||||
expect(isLoopbackUrl("https://github.com")).toBe(false);
|
||||
expect(isLoopbackUrl("not a url")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("openInPreview", () => {
|
||||
let off: (() => void) | null = null;
|
||||
afterEach(() => off?.());
|
||||
const listen = (take: boolean) => {
|
||||
const seen: string[] = [];
|
||||
const fn = (e: Event) => {
|
||||
const d = (e as CustomEvent<OpenInPreview>).detail;
|
||||
seen.push(d.url);
|
||||
if (take) d.handled = true;
|
||||
};
|
||||
window.addEventListener(OPEN_IN_PREVIEW_EVENT, fn);
|
||||
off = () => window.removeEventListener(OPEN_IN_PREVIEW_EVENT, fn);
|
||||
return seen;
|
||||
};
|
||||
|
||||
it("hands a local page to a listening panel", () => {
|
||||
const seen = listen(true);
|
||||
expect(openInPreview("http://localhost:3000/login")).toBe(true);
|
||||
expect(seen).toEqual(["http://localhost:3000/login"]);
|
||||
});
|
||||
it("falls back when no panel takes it, and never sends an external page", () => {
|
||||
const seen = listen(false);
|
||||
expect(openInPreview("http://localhost:3000")).toBe(false);
|
||||
expect(openInPreview("https://github.com/login")).toBe(false);
|
||||
expect(seen).toEqual(["http://localhost:3000/"]);
|
||||
});
|
||||
it("leaves a Ctrl/Cmd click to the browser", () => {
|
||||
listen(true);
|
||||
const preventDefault = vi.fn();
|
||||
const base = { ctrlKey: false, metaKey: false, shiftKey: false, button: 0, preventDefault };
|
||||
onLinkClick({ ...base, ctrlKey: true }, "http://localhost:3000");
|
||||
expect(preventDefault).not.toHaveBeenCalled();
|
||||
onLinkClick(base, "http://localhost:3000");
|
||||
expect(preventDefault).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* @file uploadStaged.test.ts
|
||||
* @description The resumable File Explorer upload (`api.files.uploadStaged`):
|
||||
* a file goes in parts to `/fs/upload-part` and is committed with
|
||||
* `?staged=<id>`; a dashboard that goes away mid-way (network error, a
|
||||
* proxy's bare 502) is waited out and the upload resumes from the byte the
|
||||
* server reports; a refusal with our own error code is not retried.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { api, newUploadId } from "../api";
|
||||
|
||||
type Answer = { status: number; body?: unknown } | "neterr";
|
||||
|
||||
/** A fake XHR that answers each send with the next scripted answer. */
|
||||
class FakeXhr {
|
||||
static answers: ((url: string, size: number) => Answer)[] = [];
|
||||
static sent: { url: string; size: number }[] = [];
|
||||
status = 0;
|
||||
responseText = "";
|
||||
upload: { onprogress: ((e: { loaded: number }) => void) | null } = { onprogress: null };
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
onabort: (() => void) | null = null;
|
||||
private url = "";
|
||||
open(_m: string, url: string) {
|
||||
this.url = url;
|
||||
}
|
||||
setRequestHeader() {}
|
||||
abort() {
|
||||
this.onabort?.();
|
||||
}
|
||||
send(body: Blob) {
|
||||
FakeXhr.sent.push({ url: this.url, size: body.size });
|
||||
const next = FakeXhr.answers.shift();
|
||||
const a = next ? next(this.url, body.size) : { status: 500, body: { error: { code: "EX" } } };
|
||||
queueMicrotask(() => {
|
||||
if (a === "neterr") return this.onerror?.();
|
||||
this.status = a.status;
|
||||
this.responseText = a.body === undefined ? "" : JSON.stringify(a.body);
|
||||
this.onload?.();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
const q = (url: string) => new URL(url, "http://x").searchParams;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
FakeXhr.answers = [];
|
||||
FakeXhr.sent = [];
|
||||
vi.stubGlobal("XMLHttpRequest", FakeXhr);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("api.files.uploadStaged", () => {
|
||||
it("sends parts, waits out a restart, resumes from the server's count, then commits", async () => {
|
||||
const file = new Blob([new Uint8Array(20 * MB)]);
|
||||
const upload = newUploadId();
|
||||
expect(upload).toMatch(/^[0-9a-f]{32}$/);
|
||||
// part 1 ok; part 2 dies with the server; part 2 again after the restart;
|
||||
// part 3; the commit's answer is lost once; then the stored answer.
|
||||
FakeXhr.answers = [
|
||||
() => ({ status: 200, body: { received: 8 * MB } }),
|
||||
() => "neterr",
|
||||
(url) => ({ status: 200, body: { received: Number(q(url).get("offset")) + 8 * MB } }),
|
||||
() => ({ status: 200, body: { received: 20 * MB } }),
|
||||
() => ({ status: 502 }),
|
||||
() => ({ status: 201, body: { path: "big.bin" } }),
|
||||
];
|
||||
// While away: the status read says 10 MB arrived before the server died.
|
||||
const fetchMock = vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ received: 10 * MB, total: 20 * MB, done: false }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const waits: boolean[] = [];
|
||||
const done = api.files.uploadStaged(7, "big.bin", file, "fail", {
|
||||
upload,
|
||||
onWait: (w) => waits.push(w),
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
await expect(done).resolves.toEqual({ path: "big.bin" });
|
||||
const parts = FakeXhr.sent.filter((s) => s.url.includes("/upload-part"));
|
||||
expect(parts.map((p) => [q(p.url).get("offset"), p.size])).toEqual([
|
||||
["0", 8 * MB],
|
||||
[String(8 * MB), 8 * MB],
|
||||
[String(10 * MB), 8 * MB],
|
||||
[String(18 * MB), 2 * MB],
|
||||
]);
|
||||
for (const p of parts) expect(q(p.url).get("upload")).toBe(upload);
|
||||
const commits = FakeXhr.sent.filter((s) => s.url.includes("/fs/upload?"));
|
||||
expect(commits).toHaveLength(2);
|
||||
expect(q(commits[0]!.url).get("staged")).toBe(upload);
|
||||
expect(q(commits[0]!.url).get("path")).toBe("big.bin");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(waits).toEqual([true, false, true, false]);
|
||||
});
|
||||
|
||||
it("goes on from the server's count on EOFFSET, and never retries our own refusal", async () => {
|
||||
const file = new Blob([new Uint8Array(10)]);
|
||||
FakeXhr.answers = [
|
||||
() => ({ status: 409, body: { error: { code: "EOFFSET", message: "x", received: 10 } } }),
|
||||
() => ({ status: 409, body: { error: { code: "EEXISTS", message: "taken", names: ["a"] } } }),
|
||||
];
|
||||
const done = api.files.uploadStaged(7, "a", file, "fail", { upload: newUploadId() });
|
||||
const settled = expect(done).rejects.toMatchObject({ code: "EEXISTS", names: ["a"] });
|
||||
await vi.runAllTimersAsync();
|
||||
await settled;
|
||||
expect(FakeXhr.sent).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("gives up once the dashboard has been away too long", async () => {
|
||||
const file = new Blob([new Uint8Array(4)]);
|
||||
FakeXhr.answers = Array.from({ length: 200 }, () => () => "neterr" as const);
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
throw new TypeError("Failed to fetch");
|
||||
})
|
||||
);
|
||||
const done = api.files.uploadStaged(7, "a", file, "fail", { upload: newUploadId() });
|
||||
const settled = expect(done).rejects.toMatchObject({ code: "ENET" });
|
||||
await vi.runAllTimersAsync();
|
||||
await settled;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* @file wheelReports.test.ts
|
||||
* @description A touchpad scrolls 1:1 with the finger (one report per
|
||||
* LINES_PER_REPORT rows, remainder carried), a wheel notch always sends one.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { LINES_PER_REPORT, SCROLL_SPEED, wheelReports, type WheelState } from "../wheelReports";
|
||||
|
||||
const ROW = 20;
|
||||
const px = (deltaY: number) => ({ deltaY, deltaMode: 0 });
|
||||
|
||||
describe("wheelReports", () => {
|
||||
it("adds small touchpad steps up to one report per LINES_PER_REPORT rows", () => {
|
||||
const s: WheelState = { partial: 0 };
|
||||
let sent = 0;
|
||||
// 110 steps of 10px = 1100px = 55 rows, scrolled at SCROLL_SPEED.
|
||||
for (let i = 0; i < 110; i++) sent += wheelReports(s, px(10), ROW, 40);
|
||||
expect(sent).toBe(Math.trunc((55 * SCROLL_SPEED) / LINES_PER_REPORT));
|
||||
});
|
||||
|
||||
it("drops the carried remainder when the swipe turns around", () => {
|
||||
const s: WheelState = { partial: 0 };
|
||||
wheelReports(s, px(40), ROW, 40); // 2 rows down, nothing sent yet
|
||||
let sent = 0;
|
||||
// 8 rows up = 5.6 scrolled: one report, the 2 rows down forgotten.
|
||||
for (let i = 0; i < 8; i++) sent += wheelReports(s, px(-20), ROW, 40);
|
||||
expect(sent).toBe(-1);
|
||||
});
|
||||
|
||||
it("sends at least one report for a wheel notch, in either delta mode", () => {
|
||||
expect(wheelReports({ partial: 0 }, px(100), ROW, 40)).toBe(1);
|
||||
expect(wheelReports({ partial: 0 }, px(-100), ROW, 40)).toBe(-1);
|
||||
expect(wheelReports({ partial: 0 }, { deltaY: 3, deltaMode: 1 }, ROW, 40)).toBe(1);
|
||||
expect(wheelReports({ partial: 0 }, { deltaY: 1, deltaMode: 2 }, ROW, 40)).toBe(6); // 8 × 0.7
|
||||
});
|
||||
|
||||
it("slows a run of wheel notches to SCROLL_SPEED of a report each", () => {
|
||||
const s: WheelState = { partial: 0 };
|
||||
let sent = 0;
|
||||
for (let i = 0; i < 10; i++) sent += wheelReports(s, px(100), ROW, 40);
|
||||
expect(sent).toBe(10 * SCROLL_SPEED);
|
||||
// Turning around starts from zero: the first notch up moves at once.
|
||||
expect(wheelReports(s, px(-100), ROW, 40)).toBe(-1);
|
||||
});
|
||||
|
||||
it("sends nothing for a horizontal-only event or an unmeasured terminal", () => {
|
||||
expect(wheelReports({ partial: 0 }, px(0), ROW, 40)).toBe(0);
|
||||
expect(wheelReports({ partial: 0 }, px(10), 0, 40)).toBe(0);
|
||||
});
|
||||
});
|
||||
+318
-25
@@ -547,6 +547,38 @@ function ccQuery(scope?: CcScope, cwd?: string): string {
|
||||
/** Fired on `window` when a request answers 401 `EAUTH` (server mode). */
|
||||
export const AUTH_REQUIRED_EVENT = "agenthub:auth-required";
|
||||
|
||||
/** Someone to chat with (`GET /api/chat/people`). */
|
||||
export type ChatPerson = {
|
||||
id: number;
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
avatarAt: string | null;
|
||||
};
|
||||
/** A file of a chat message (`GET /api/chat/rooms/:id/files/:fileId`). */
|
||||
export type ChatFile = { id: string; name: string; type: string; size: number };
|
||||
/** One chat message; `system` bodies: created, renamed, left, added:<ids>, removed:<ids>. */
|
||||
export type ChatMessage = {
|
||||
id: number;
|
||||
roomId: number;
|
||||
userId: number | null;
|
||||
kind: "text" | "system";
|
||||
body: string;
|
||||
createdAt: string;
|
||||
files: ChatFile[];
|
||||
};
|
||||
/** A direct chat or a group, as the caller sees it. */
|
||||
export type ChatRoom = {
|
||||
id: number;
|
||||
kind: "dm" | "group";
|
||||
name: string;
|
||||
createdBy: number | null;
|
||||
createdAt: string;
|
||||
lastMessageAt: string | null;
|
||||
members: number[];
|
||||
lastMessage: ChatMessage | null;
|
||||
lastReadId: number;
|
||||
unread: number;
|
||||
};
|
||||
/** One account in Settings → Users (`GET /api/users`). */
|
||||
export type ServerUserUsage = AuthUser & {
|
||||
disabled: boolean;
|
||||
@@ -1188,7 +1220,9 @@ const sendRaw = <T>(
|
||||
xhr.upload.onprogress = (e) => onProgress?.(e.loaded);
|
||||
xhr.onerror = () => reject(Object.assign(new Error("network error"), { code: "ENET" }));
|
||||
xhr.onload = () => {
|
||||
let body: { error?: { code?: string; message?: string; names?: string[] } } = {};
|
||||
let body: {
|
||||
error?: { code?: string; message?: string; names?: string[]; received?: number };
|
||||
} = {};
|
||||
try {
|
||||
body = JSON.parse(xhr.responseText);
|
||||
} catch {
|
||||
@@ -1206,12 +1240,148 @@ const sendRaw = <T>(
|
||||
code: body.error?.code,
|
||||
status: xhr.status,
|
||||
names: body.error?.names,
|
||||
received: body.error?.received,
|
||||
})
|
||||
);
|
||||
};
|
||||
xhr.send(payload);
|
||||
});
|
||||
|
||||
/**
|
||||
* A batch body (`/fs/upload-batch`): a 4-byte length, the JSON file list,
|
||||
* then each file's bytes. The Blob is only composed, never read into memory.
|
||||
*/
|
||||
function batchBody(files: { file: Blob; rel: string }[]): Blob {
|
||||
const head = new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
files: files.map((f) => ({
|
||||
path: f.rel,
|
||||
size: f.file.size,
|
||||
mtime: f.file instanceof File ? f.file.lastModified : undefined,
|
||||
})),
|
||||
})
|
||||
);
|
||||
const len = new Uint8Array(4);
|
||||
new DataView(len.buffer).setUint32(0, head.length);
|
||||
return new Blob([len, head, ...files.map((f) => f.file)], { type: "application/octet-stream" });
|
||||
}
|
||||
|
||||
/** One part of a resumable upload (the server takes at most 16 MB). */
|
||||
const UPLOAD_PART_BYTES = 8 * 1024 * 1024;
|
||||
/** How long an upload waits for the dashboard to come back (a restart, an update). */
|
||||
const UPLOAD_WAIT_MS = 3 * 60 * 1000;
|
||||
|
||||
/** A fresh id for one resumable upload (also outside a secure context). */
|
||||
export function newUploadId(): string {
|
||||
const b = new Uint8Array(16);
|
||||
crypto.getRandomValues(b);
|
||||
return Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
type SendErr = Error & { code?: string; status?: number; received?: number };
|
||||
|
||||
/**
|
||||
* The dashboard is away (restarting) rather than refusing: worth waiting for.
|
||||
* A 5xx with none of our error codes is a proxy (nginx, Vite) answering for it.
|
||||
*/
|
||||
const isAway = (e: SendErr) =>
|
||||
e.code === "ENET" || e.code === "EBUSY" || ((e.status ?? 0) >= 500 && !e.code);
|
||||
|
||||
const pause = (ms: number, signal?: AbortSignal) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, ms);
|
||||
signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
clearTimeout(timer);
|
||||
reject(Object.assign(new Error("aborted"), { code: "EABORT" }));
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
|
||||
/** Callbacks of a resumable upload. */
|
||||
export interface StagedSendOpts {
|
||||
/** The upload's id: pass the SAME one to retry a commit (another `conflict`) without resending. */
|
||||
upload: string;
|
||||
onProgress?: (sent: number) => void;
|
||||
/** true while the dashboard is away and the upload waits to resume. */
|
||||
onWait?: (waiting: boolean) => void;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send `payload` in parts to `place`'s staging (`/fs/upload-part`), then
|
||||
* POST `commitUrl(id)` to write it. A dropped connection or a restarting
|
||||
* dashboard (network error, 502/503/504) is waited out for up to
|
||||
* UPLOAD_WAIT_MS, and the upload resumes from the byte the server has.
|
||||
*/
|
||||
async function sendStaged<T>(
|
||||
place: FsPlace,
|
||||
commitUrl: (id: string) => string,
|
||||
payload: Blob,
|
||||
{ upload, onProgress, onWait, signal }: StagedSendOpts
|
||||
): Promise<T> {
|
||||
const total = payload.size;
|
||||
let received = 0;
|
||||
let awaySince = 0;
|
||||
let delay = 500;
|
||||
const waitOut = async (e: SendErr) => {
|
||||
if (!isAway(e)) throw e;
|
||||
if (!awaySince) awaySince = Date.now();
|
||||
if (Date.now() - awaySince > UPLOAD_WAIT_MS) throw e;
|
||||
onWait?.(true);
|
||||
await pause(delay, signal);
|
||||
delay = Math.min(delay * 2, 5000);
|
||||
};
|
||||
const back = () => {
|
||||
if (awaySince) onWait?.(false);
|
||||
awaySince = 0;
|
||||
delay = 500;
|
||||
};
|
||||
const partUrl = (q: Record<string, string>) =>
|
||||
fsUrl(place, "/upload-part", new URLSearchParams({ upload, ...q }));
|
||||
// An empty file is still one (empty) part: it makes the upload known.
|
||||
do {
|
||||
try {
|
||||
const r = await sendRaw<{ received: number }>(
|
||||
partUrl({ offset: String(received), total: String(total) }),
|
||||
payload.slice(received, received + UPLOAD_PART_BYTES),
|
||||
(n) => onProgress?.(received + n),
|
||||
signal
|
||||
);
|
||||
received = r.received;
|
||||
back();
|
||||
} catch (err) {
|
||||
const e = err as SendErr;
|
||||
if (e.code === "EOFFSET" && typeof e.received === "number") {
|
||||
// The server has more (or less) than we thought: go on from there.
|
||||
received = e.received;
|
||||
back();
|
||||
continue;
|
||||
}
|
||||
await waitOut(e);
|
||||
try {
|
||||
const s = await request<{ received: number; done: boolean }>(partUrl({}));
|
||||
received = s.received;
|
||||
} catch {
|
||||
/* still away: the next part asks again */
|
||||
}
|
||||
}
|
||||
onProgress?.(received);
|
||||
} while (received < total);
|
||||
for (;;) {
|
||||
try {
|
||||
const r = await sendRaw<T>(commitUrl(upload), new Blob([]), undefined, signal);
|
||||
back();
|
||||
return r;
|
||||
} catch (err) {
|
||||
// A commit whose answer was lost is answered again, never written twice.
|
||||
await waitOut(err as SendErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A home the File Explorer can open (`GET /api/homes`): `me`, or another user's for an admin. */
|
||||
export interface FsHome {
|
||||
id: string;
|
||||
@@ -1429,8 +1599,57 @@ export const api = {
|
||||
request<HostHistory>(`/host/history?range=${range}`),
|
||||
},
|
||||
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
|
||||
/** `/api/chat` — chat between users in server mode (the pet's window). */
|
||||
chat: {
|
||||
people: () => request<{ people: ChatPerson[] }>("/chat/people"),
|
||||
rooms: () => request<{ rooms: ChatRoom[] }>("/chat/rooms"),
|
||||
/** The one direct chat with `userId`, made on first use. */
|
||||
openDm: (userId: number) =>
|
||||
request<{ room: ChatRoom }>("/chat/rooms", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ kind: "dm", userId }),
|
||||
}),
|
||||
createGroup: (name: string, userIds: number[]) =>
|
||||
request<{ room: ChatRoom }>("/chat/rooms", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ kind: "group", name, userIds }),
|
||||
}),
|
||||
/** The group's creator only. */
|
||||
updateGroup: (id: number, patch: { name?: string; add?: number[]; remove?: number[] }) =>
|
||||
request<{ room: ChatRoom }>(`/chat/rooms/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(patch),
|
||||
}),
|
||||
leave: (id: number) => request<{ ok: true }>(`/chat/rooms/${id}/leave`, { method: "POST" }),
|
||||
messages: (id: number, before?: number) =>
|
||||
request<{ messages: ChatMessage[]; hasMore: boolean }>(
|
||||
`/chat/rooms/${id}/messages${before ? `?before=${before}` : ""}`
|
||||
),
|
||||
/** Text and files (multipart when there are files). */
|
||||
post: (id: number, body: string, files: File[] = []) => {
|
||||
if (!files.length)
|
||||
return request<{ message: ChatMessage }>(`/chat/rooms/${id}/messages`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body }),
|
||||
});
|
||||
const form = new FormData();
|
||||
form.append("body", body);
|
||||
for (const f of files) form.append("files", f, f.name);
|
||||
return request<{ message: ChatMessage }>(`/chat/rooms/${id}/messages`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
},
|
||||
read: (id: number, upTo: number) =>
|
||||
request<{ lastReadId: number }>(`/chat/rooms/${id}/read`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ upTo }),
|
||||
}),
|
||||
fileUrl: (roomId: number, fileId: string) =>
|
||||
`${BASE}/chat/rooms/${roomId}/files/${encodeURIComponent(fileId)}`,
|
||||
},
|
||||
users: {
|
||||
/** URL of one account's avatar (admin/analyst), versioned by `avatarAt` (null = none). */
|
||||
/** URL of one account's avatar (any signed-in account), versioned by `avatarAt` (null = none). */
|
||||
avatarUrl: (id: number, avatarAt?: string | null) =>
|
||||
avatarAt ? `${BASE}/users/${id}/avatar?v=${encodeURIComponent(avatarAt)}` : null,
|
||||
/** GET /api/users — every account with its recent usage (admin/analyst). */
|
||||
@@ -1720,6 +1939,8 @@ export const api = {
|
||||
archived?: boolean;
|
||||
/** Pinned sessions first (by pin time), before the limit. */
|
||||
pinnedFirst?: boolean;
|
||||
/** Server mode: the caller's OWN sessions, even for an admin/analyst (`mine=1`). */
|
||||
mine?: boolean;
|
||||
/** Only active sessions waiting on the user (`awaiting=1`). */
|
||||
awaiting?: boolean;
|
||||
/** ISO bounds on `started_at`. */
|
||||
@@ -1740,6 +1961,7 @@ export const api = {
|
||||
if (params?.archived !== undefined) qs.set("archived", params.archived ? "1" : "0");
|
||||
if (params?.pinnedFirst) qs.set("pinned_first", "1");
|
||||
if (params?.awaiting) qs.set("awaiting", "1");
|
||||
if (params?.mine) qs.set("mine", "1");
|
||||
if (params?.from) qs.set("from", params.from);
|
||||
if (params?.to) qs.set("to", params.to);
|
||||
if (params?.sort_by) qs.set("sort_by", params.sort_by);
|
||||
@@ -3300,6 +3522,28 @@ export const api = {
|
||||
onProgress,
|
||||
signal
|
||||
),
|
||||
/**
|
||||
* {@link files.upload}, resumable: sent in parts and written once whole,
|
||||
* so a dashboard restart mid-way only pauses it ({@link sendStaged}).
|
||||
*/
|
||||
uploadStaged: (
|
||||
t: FsPlace,
|
||||
path: string,
|
||||
file: Blob,
|
||||
conflict: FsConflict,
|
||||
opts: StagedSendOpts
|
||||
) =>
|
||||
sendStaged<{ path: string; skipped?: boolean }>(
|
||||
t,
|
||||
(staged) => fsUrl(t, "/upload", new URLSearchParams({ path, conflict, staged })),
|
||||
file,
|
||||
opts
|
||||
),
|
||||
/** Forget a resumable upload's staged bytes (stopped, or given up). Never throws. */
|
||||
dropUpload: (t: FsPlace, upload: string) =>
|
||||
request<{ dropped: boolean }>(fsUrl(t, "/upload-part", new URLSearchParams({ upload })), {
|
||||
method: "DELETE",
|
||||
}).catch(() => null),
|
||||
/**
|
||||
* Many small files in ONE request, under the folder `dir` (each `rel`
|
||||
* relative to it): a 4-byte length, the JSON file list, then the bytes.
|
||||
@@ -3312,25 +3556,27 @@ export const api = {
|
||||
conflict: FsConflict = "fail",
|
||||
onProgress?: (sent: number) => void,
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
const head = new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
files: files.map((f) => ({
|
||||
path: f.rel,
|
||||
size: f.file.size,
|
||||
mtime: f.file instanceof File ? f.file.lastModified : undefined,
|
||||
})),
|
||||
})
|
||||
);
|
||||
const len = new Uint8Array(4);
|
||||
new DataView(len.buffer).setUint32(0, head.length);
|
||||
return sendRaw<{ saved: number; skipped: number; paths: string[] }>(
|
||||
) =>
|
||||
sendRaw<{ saved: number; skipped: number; paths: string[] }>(
|
||||
fsUrl(t, "/upload-batch", new URLSearchParams({ dir, conflict })),
|
||||
new Blob([len, head, ...files.map((f) => f.file)], { type: "application/octet-stream" }),
|
||||
batchBody(files),
|
||||
onProgress,
|
||||
signal
|
||||
);
|
||||
},
|
||||
),
|
||||
/** {@link files.uploadBatch}, resumable like {@link files.uploadStaged}. */
|
||||
uploadBatchStaged: (
|
||||
t: FsPlace,
|
||||
dir: string,
|
||||
files: { file: Blob; rel: string }[],
|
||||
conflict: FsConflict,
|
||||
opts: StagedSendOpts
|
||||
) =>
|
||||
sendStaged<{ saved: number; skipped: number; paths: string[] }>(
|
||||
t,
|
||||
(staged) => fsUrl(t, "/upload-batch", new URLSearchParams({ dir, conflict, staged })),
|
||||
batchBody(files),
|
||||
opts
|
||||
),
|
||||
/** Before a batch: which of `paths` (under `dir`) are taken, and whether a batch can go there. */
|
||||
uploadCheck: (t: FsPlace, dir: string, paths: string[]) =>
|
||||
request<{ taken: string[]; batch: boolean }>(fsUrl(t, "/upload-check"), {
|
||||
@@ -3356,6 +3602,32 @@ export const api = {
|
||||
/** DELETE /api/me/git/ssh-key?id= — one key by its id (a named one). */
|
||||
removeKeyById: (id: string) =>
|
||||
request<GitIdentity>(`/me/git/ssh-key?id=${encodeURIComponent(id)}`, { method: "DELETE" }),
|
||||
/**
|
||||
* POST /api/me/git/ssh-key/import — a private key the user already has
|
||||
* (the file's text); `sign` signs commits with it at once, `replace`
|
||||
* over a signing key they set themselves. Only its public half returns.
|
||||
*/
|
||||
importKey: (body: { privateKey: string; name?: string; sign?: boolean; replace?: boolean }) =>
|
||||
request<GitIdentity & { imported: GitSshKey | null; signError: ApiErrorBody | null }>(
|
||||
"/me/git/ssh-key/import",
|
||||
{ method: "POST", body: JSON.stringify(body) }
|
||||
),
|
||||
/**
|
||||
* PUT /api/me/git/signing — the account-wide setting: sign every commit
|
||||
* with key `key` (its id), or stop (null); `clearForeign` also takes out a
|
||||
* signing setup the user made themselves (their key file stays).
|
||||
*/
|
||||
setSigning: (key: string | null, replace = false, clearForeign = false) =>
|
||||
request<GitIdentity>("/me/git/signing", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(clearForeign ? { key, replace, clearForeign } : { key, replace }),
|
||||
}),
|
||||
/** PUT /api/me/git/signing/lane — sign ONE workspace's commits with `key`, or stop (null). */
|
||||
setLaneSigning: (laneId: number, key: string | null) =>
|
||||
request<GitIdentity>("/me/git/signing/lane", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ laneId, key }),
|
||||
}),
|
||||
/** PUT /api/me/git/ssh-terminal — use the key for git typed in the account's terminal. */
|
||||
setTerminalSsh: (on: boolean) =>
|
||||
request<GitIdentity>("/me/git/ssh-terminal", { method: "PUT", body: JSON.stringify({ on }) }),
|
||||
@@ -5232,6 +5504,21 @@ export interface GitCredential {
|
||||
hasToken: true;
|
||||
}
|
||||
|
||||
/** One SSH key of `GET /api/me/git` — its public half only. */
|
||||
export interface GitSshKey {
|
||||
id?: string;
|
||||
type?: "ed25519" | "rsa";
|
||||
name?: string | null;
|
||||
publicKey: string;
|
||||
fingerprint: string;
|
||||
}
|
||||
|
||||
/** An API error's `{code, message}`. */
|
||||
export interface ApiErrorBody {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** `GET /api/me/git`: the user's own SSH key (public half only) and HTTPS logins. */
|
||||
export interface GitIdentity {
|
||||
/** `member`: the user's own Linux account (S3); `host`: the shared host account. */
|
||||
@@ -5249,13 +5536,19 @@ export interface GitIdentity {
|
||||
* Every key the account holds: the default ED25519 and RSA first, then the
|
||||
* named ones (`name`; `id` = its file name); absent from an older server.
|
||||
*/
|
||||
sshKeys?: {
|
||||
id?: string;
|
||||
type?: "ed25519" | "rsa";
|
||||
name?: string | null;
|
||||
publicKey: string;
|
||||
fingerprint: string;
|
||||
}[];
|
||||
sshKeys?: GitSshKey[];
|
||||
/**
|
||||
* Commit signing (`gpg.format ssh` + `user.signingkey` + `commit.gpgsign`
|
||||
* in `~/.gitconfig`): `on` with one of these keys (`key` = its id), `off`,
|
||||
* or `foreign` — a signing key the user set themselves (`path`). Absent
|
||||
* from an older server.
|
||||
*/
|
||||
signing?: { state: "on" | "off" | "foreign"; key: string | null; path: string | null };
|
||||
/**
|
||||
* The workspaces whose commits are signed (an `includeIf "gitdir:…"` each):
|
||||
* `key` = the key's id, null when its file is gone. Absent from an older server.
|
||||
*/
|
||||
laneSigning?: { laneId: number; key: string | null }[];
|
||||
/** The terminal switch (shared by both keys); null without a key. */
|
||||
sshTerminal?: "on" | "off" | "foreign" | null;
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @file openInPreview.ts — the ONE rule every link in the dashboard follows
|
||||
* (a chat's markdown link or bare URL, a terminal's link): a page served by
|
||||
* THIS machine (`localhost`, `127.0.0.1`, `0.0.0.0`, `[::1]`) opens in the
|
||||
* session view's Preview tab, everything else in a browser tab as before.
|
||||
* The request travels as a window event (like `openInChanges.ts`); the
|
||||
* Workspace page answers it only while it shows a session panel, so with no
|
||||
* listener — another page, or a remote dashboard where "localhost" is the
|
||||
* viewer's own machine — the link falls back to a browser tab. Also holds the
|
||||
* URL checks the Preview tab itself uses.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
export const OPEN_IN_PREVIEW_EVENT = "agenthub:open-in-preview";
|
||||
|
||||
/** The event's detail. `handled` is set by the listener, read by the sender. */
|
||||
export type OpenInPreview = { url: string; handled: boolean };
|
||||
|
||||
const LOOPBACK = new Set(["localhost", "127.0.0.1", "0.0.0.0", "[::1]", "::1"]);
|
||||
|
||||
/** A URL the Preview may show: http(s) only, `0.0.0.0` read as `localhost`
|
||||
* (a dev server prints it, a browser cannot open it), never the dashboard's
|
||||
* own origin (the frame would then BE the dashboard, cookies and API
|
||||
* included). A bare `localhost:3000` gets `http://`. Null when refused. */
|
||||
export function normalizePreviewUrl(raw: string, self = window.location.origin): string | null {
|
||||
let text = raw.trim();
|
||||
if (!text) return null;
|
||||
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(text)) text = `http://${text}`;
|
||||
let u: URL;
|
||||
try {
|
||||
u = new URL(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
|
||||
if (u.username || u.password) return null;
|
||||
if (u.hostname === "0.0.0.0") u.hostname = "localhost";
|
||||
if (u.origin === self) return null;
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
/** True for a page served by the machine the BROWSER runs on. */
|
||||
export function isLoopbackUrl(url: string): boolean {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return (u.protocol === "http:" || u.protocol === "https:") && LOOPBACK.has(u.hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** The dashboard itself is opened from this machine: only then does a
|
||||
* `localhost` link name the same machine the agents run on. */
|
||||
export function dashboardIsLocal(hostname = window.location.hostname): boolean {
|
||||
return LOOPBACK.has(hostname);
|
||||
}
|
||||
|
||||
/** Asks the open session panel to show `url`. False when nothing took it
|
||||
* (not a local page, a remote dashboard, no session panel). */
|
||||
export function openInPreview(url: string): boolean {
|
||||
if (!isLoopbackUrl(url) || !dashboardIsLocal()) return false;
|
||||
const target = normalizePreviewUrl(url);
|
||||
if (!target) return false;
|
||||
const detail: OpenInPreview = { url: target, handled: false };
|
||||
window.dispatchEvent(new CustomEvent(OPEN_IN_PREVIEW_EVENT, { detail }));
|
||||
return detail.handled;
|
||||
}
|
||||
|
||||
/** A link click: Preview when the rule says so, else a browser tab. A
|
||||
* Ctrl/Cmd/Shift/middle click always keeps the browser's own behaviour. */
|
||||
export function onLinkClick(
|
||||
e: {
|
||||
ctrlKey: boolean;
|
||||
metaKey: boolean;
|
||||
shiftKey: boolean;
|
||||
button: number;
|
||||
preventDefault: () => void;
|
||||
},
|
||||
url: string
|
||||
): void {
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey || e.button !== 0) return;
|
||||
if (openInPreview(url)) e.preventDefault();
|
||||
}
|
||||
+11
-2
@@ -1917,10 +1917,13 @@ export interface WSMessage {
|
||||
* alert_triggered/alert_updated → AlertEvent; workflow_upserted → WorkflowRun;
|
||||
* remote_source.status → RemoteSourceStatusPayload;
|
||||
* remote_data.updated → RemoteDataUpdatedPayload;
|
||||
* lane_update → { lane?: Lane; removed?: number }. */
|
||||
* lane_update → { lane?: Lane; removed?: number };
|
||||
* chat_message → { roomId, message: ChatMessage, to }; chat_room → { roomId, to }. */
|
||||
type:
|
||||
| "session_created"
|
||||
| "session_updated"
|
||||
| "chat_message"
|
||||
| "chat_room"
|
||||
| "agent_created"
|
||||
| "agent_updated"
|
||||
| "new_event"
|
||||
@@ -1967,10 +1970,16 @@ export interface WSMessage {
|
||||
| { lane?: Lane; removed?: number }
|
||||
| LaneHookOutputPayload
|
||||
| LaneRuntimePayload
|
||||
| LaneHookResultPayload;
|
||||
| LaneHookResultPayload
|
||||
/** chat_message / chat_room (lib/api.ts ChatMessage). */
|
||||
| { roomId: number; message?: unknown; to?: number[] };
|
||||
/** ISO timestamp the server broadcast this message (not necessarily the
|
||||
* same instant the underlying event occurred). */
|
||||
timestamp: string;
|
||||
/** Server mode, an admin/analyst's session/agent/event frames only: whether
|
||||
* the session is their OWN (they also get every other user's). Absent for a
|
||||
* developer (who only ever gets their own) and in local mode. */
|
||||
mine?: boolean;
|
||||
}
|
||||
|
||||
// ───── Session stats ─────
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
* kept in localStorage (refreshed every ~2 s), and the next page load shows
|
||||
* a record that stopped being refreshed as an interrupted upload until it is
|
||||
* dismissed. A fresh record belongs to another open tab and is left alone.
|
||||
* A dashboard RESTART is not an interruption: the upload is sent in parts
|
||||
* (`api.files.uploadStaged`) and the job shows `waiting` until it resumes.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -25,6 +27,8 @@ export interface UploadJob {
|
||||
sent: number;
|
||||
bytes: number;
|
||||
state: "running" | "stopping" | "interrupted";
|
||||
/** The dashboard is away (a restart): the upload waits and then resumes. */
|
||||
waiting?: boolean;
|
||||
}
|
||||
|
||||
type Stored = UploadJob & { beat: number };
|
||||
@@ -115,7 +119,8 @@ export function beginUpload(init: { folder: string; total: number; bytes: number
|
||||
return {
|
||||
id,
|
||||
signal: ctrl.signal,
|
||||
update: (patch: Partial<Pick<UploadJob, "name" | "done" | "sent">>) => set(id, patch),
|
||||
update: (patch: Partial<Pick<UploadJob, "name" | "done" | "sent" | "waiting">>) =>
|
||||
set(id, patch),
|
||||
end: () => {
|
||||
controllers.delete(id);
|
||||
uploads = uploads.filter((u) => u.id !== id);
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* @file wheelReports.ts — turns a browser wheel event into the number of SGR
|
||||
* wheel reports a terminal pane gets, in proportion to how far the finger or
|
||||
* the wheel moved, the way a native terminal does.
|
||||
*
|
||||
* xterm.js sends ONE report per wheel event whatever its size, and damps a
|
||||
* touchpad's pixel deltas ×0.3 before it counts even that one — a touchpad
|
||||
* swipe crawled. In our panes every report moves a fixed stretch (Claude's
|
||||
* `CLAUDE_CODE_SCROLL_SPEED`, tmux's copy-mode `WHEEL_LINES`), so a report is
|
||||
* sent per `LINES_PER_REPORT` rows of travel: a touchpad scrolls 1:1 with the
|
||||
* finger, and a wheel notch always sends at least one. `SCROLL_SPEED` then
|
||||
* slows both down (the user asked for 30 % less): a touchpad's travel is
|
||||
* scaled, and a notch's reports are carried as a fraction, rounded, so the
|
||||
* first notch still moves and the average comes out at that share.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
/** Rows one report scrolls in a Claude pane (server/lib/pty-run.js CLAUDE_SCROLL_SPEED). */
|
||||
export const LINES_PER_REPORT = 5;
|
||||
|
||||
/** Share of the travel a pane scrolls (0.7 = 30 % slower than 1:1). */
|
||||
export const SCROLL_SPEED = 0.7;
|
||||
|
||||
/** A pixel delta this large or larger is a wheel notch, not a touchpad step (xterm.js's own cut). */
|
||||
const NOTCH_PX = 50;
|
||||
|
||||
const DOM_DELTA_PIXEL = 0;
|
||||
const DOM_DELTA_LINE = 1;
|
||||
const DOM_DELTA_PAGE = 2;
|
||||
|
||||
export interface WheelState {
|
||||
/** Touchpad travel not yet sent, in rows (signed). */
|
||||
partial: number;
|
||||
/** Wheel-notch reports owed but not sent yet (signed, a fraction). */
|
||||
notch?: number;
|
||||
/** Direction of the last notch (the carry above may round to either sign). */
|
||||
notchDir?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signed report count for `ev` — negative scrolls up. `rowPx` is one row's
|
||||
* height on screen (CSS scale included), `rows` the terminal's height.
|
||||
*/
|
||||
export function wheelReports(
|
||||
state: WheelState,
|
||||
ev: Pick<WheelEvent, "deltaY" | "deltaMode">,
|
||||
rowPx: number,
|
||||
rows: number
|
||||
): number {
|
||||
if (!ev.deltaY || !(rowPx > 0)) return 0;
|
||||
const sign = ev.deltaY < 0 ? -1 : 1;
|
||||
if (ev.deltaMode === DOM_DELTA_PIXEL && Math.abs(ev.deltaY) < NOTCH_PX) {
|
||||
// Touchpad: carry the remainder, so slow swipes still add up.
|
||||
if (Math.sign(state.partial) === -sign) state.partial = 0; // turned around
|
||||
state.partial += (ev.deltaY / rowPx) * SCROLL_SPEED;
|
||||
const n = Math.trunc(state.partial / LINES_PER_REPORT);
|
||||
state.partial -= n * LINES_PER_REPORT;
|
||||
return n;
|
||||
}
|
||||
// A notch (or a fast flick): at least one report, more for a long one.
|
||||
state.partial = 0;
|
||||
if (state.notchDir !== sign) state.notch = 0; // turned around
|
||||
state.notchDir = sign;
|
||||
const lines =
|
||||
ev.deltaMode === DOM_DELTA_LINE
|
||||
? ev.deltaY
|
||||
: ev.deltaMode === DOM_DELTA_PAGE
|
||||
? ev.deltaY * rows
|
||||
: ev.deltaY / rowPx;
|
||||
const owed =
|
||||
(state.notch ?? 0) +
|
||||
sign * Math.max(1, Math.round(Math.abs(lines) / LINES_PER_REPORT)) * SCROLL_SPEED;
|
||||
const n = sign * Math.round(Math.abs(owed));
|
||||
state.notch = owed - n;
|
||||
return n;
|
||||
}
|
||||
@@ -141,6 +141,8 @@ import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
|
||||
import { FileExplorer } from "../components/lanes/FileExplorer";
|
||||
import { WorkspaceContext } from "../components/lanes/WorkspaceContext";
|
||||
import { OPEN_IN_CHANGES_EVENT, type OpenInChanges } from "../lib/openInChanges";
|
||||
import { OPEN_IN_PREVIEW_EVENT, type OpenInPreview } from "../lib/openInPreview";
|
||||
import { UrlPreview } from "../components/lanes/UrlPreview";
|
||||
import { LaneGitRailCounts } from "../components/lanes/LaneGitHeader";
|
||||
import { WorkspaceHome } from "../components/lanes/WorkspaceHome";
|
||||
import { SessionReplay } from "../components/lanes/SessionReplay";
|
||||
@@ -148,6 +150,8 @@ import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
|
||||
|
||||
/** localStorage key for the Changes panel's open/closed state. */
|
||||
const CHANGES_OPEN_KEY = "agenthub.workspace.changesOpen";
|
||||
/** The session panel's tab — Changes or Preview. A viewer preference. */
|
||||
const PANEL_TAB_KEY = "agenthub.workspace.panelTab";
|
||||
/** localStorage key for how wide the Changes panel is. */
|
||||
const CHANGES_SIZE_KEY = "agenthub.workspace.changesSize";
|
||||
/** localStorage key for the last width the user dragged the panel to — what
|
||||
@@ -799,6 +803,25 @@ export function Workspace() {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
// The panel's two tabs: Changes (git + files) and Preview (a web page).
|
||||
// Both stay mounted once shown (CSS), so an unsaved edit or a loaded page
|
||||
// survives a switch.
|
||||
const [panelTab, setPanelTab] = useState<"changes" | "preview">(() => {
|
||||
try {
|
||||
return window.localStorage.getItem(PANEL_TAB_KEY) === "preview" ? "preview" : "changes";
|
||||
} catch {
|
||||
return "changes";
|
||||
}
|
||||
});
|
||||
const [previewShown, setPreviewShown] = useState(panelTab === "preview");
|
||||
useEffect(() => {
|
||||
if (panelTab === "preview") setPreviewShown(true);
|
||||
try {
|
||||
window.localStorage.setItem(PANEL_TAB_KEY, panelTab);
|
||||
} catch {
|
||||
/* disabled storage: the tab just forgets between visits */
|
||||
}
|
||||
}, [panelTab]);
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(CHANGES_OPEN_KEY, changesOpen ? "1" : "0");
|
||||
@@ -1050,6 +1073,22 @@ export function Workspace() {
|
||||
window.addEventListener(OPEN_IN_CHANGES_EVENT, onOpen);
|
||||
return () => window.removeEventListener(OPEN_IN_CHANGES_EVENT, onOpen);
|
||||
}, []);
|
||||
// A link to a local page asks for it (`openInPreview`): taken only while a
|
||||
// session panel is on screen, so elsewhere the link opens a browser tab.
|
||||
const [previewRequest, setPreviewRequest] = useState<{ url: string; nonce: number } | null>(null);
|
||||
const previewHostRef = useRef(false);
|
||||
useEffect(() => {
|
||||
const onOpen = (e: Event) => {
|
||||
const detail = (e as CustomEvent<OpenInPreview>).detail;
|
||||
if (!previewHostRef.current) return;
|
||||
detail.handled = true;
|
||||
setChangesOpen(true);
|
||||
setPanelTab("preview");
|
||||
setPreviewRequest((r) => ({ url: detail.url, nonce: (r?.nonce ?? 0) + 1 }));
|
||||
};
|
||||
window.addEventListener(OPEN_IN_PREVIEW_EVENT, onOpen);
|
||||
return () => window.removeEventListener(OPEN_IN_PREVIEW_EVENT, onOpen);
|
||||
}, []);
|
||||
// Asked once: a panel reopened or moved to another tree must not replay it.
|
||||
useEffect(() => {
|
||||
if (!changesOpen) setChangesRequest(null);
|
||||
@@ -1230,6 +1269,7 @@ export function Workspace() {
|
||||
// The session view (console or replay) fills the page edge to edge, like an
|
||||
// editor: no page padding, no cards, dividers between its regions.
|
||||
const sessionView = !!currentLane && !home;
|
||||
previewHostRef.current = sessionView;
|
||||
const viewportLocked =
|
||||
sessionView || (!home && (paneHasActiveRun || (!!currentLane && changesOpen && changesFull)));
|
||||
|
||||
@@ -1567,9 +1607,25 @@ export function Workspace() {
|
||||
className="flex h-10 shrink-0 items-center gap-1 border-b border-border px-2"
|
||||
role="toolbar"
|
||||
>
|
||||
<span className="px-1 text-xs font-medium text-fg-secondary">
|
||||
{tLanes("panel.changes")}
|
||||
</span>
|
||||
<div role="tablist" className="flex items-center gap-0.5">
|
||||
{(["changes", "preview"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
role="tab"
|
||||
data-testid={`panel-tab-${tab}`}
|
||||
aria-selected={panelTab === tab}
|
||||
onClick={() => setPanelTab(tab)}
|
||||
className={`rounded-md px-2 py-1 text-xs font-medium transition-colors ${
|
||||
panelTab === tab
|
||||
? "bg-surface-4 text-fg-primary"
|
||||
: "text-fg-muted hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{tLanes(tab === "changes" ? "panel.changes" : "panel.preview")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* The opened session's analysis page — a page of its own. */}
|
||||
<button
|
||||
type="button"
|
||||
@@ -1652,7 +1708,22 @@ export function Workspace() {
|
||||
<PanelRightClose className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col px-3 pt-2">
|
||||
{previewShown && (
|
||||
<div
|
||||
className={panelTab === "preview" ? "flex min-h-0 flex-1 flex-col" : "hidden"}
|
||||
>
|
||||
<UrlPreview
|
||||
laneId={currentLane.id}
|
||||
ports={currentLane.ports ?? {}}
|
||||
request={previewRequest}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={
|
||||
panelTab === "changes" ? "flex min-h-0 flex-1 flex-col px-3 pt-2" : "hidden"
|
||||
}
|
||||
>
|
||||
{panelAt &&
|
||||
[
|
||||
...(showRoot
|
||||
|
||||
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.8.56 (rebuild client to match)
|
||||
UI build v2.8.65 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+59
-2
File diff suppressed because one or more lines are too long
@@ -416,6 +416,7 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
|
||||
- **Tabs** — the session view's console is a tab bar (`client/src/components/run/ConsoleTabs.tsx`) over the workspace's live tmux sessions — at most ten, its slots 0–9: slot 0 is the workspace's `agenthub-lane-<id>` (its `run_id`), slots 1–9 are `agenthub-lane-<id>-<slot>`. Every tab is a **combo**: a group of one to four split panes, each session in exactly one tab (`PaneGroups` in `client/src/lib/paneLayout.ts`). A one-pane tab shows its AI tool's icon and name (or **Terminal**); a combo tab shows every pane's icon and name, and the most urgent state dot. The active tab is the one on screen and its focused pane is the URL's `?run=`, so the Changes panel and Analyst follow it. A click shows a tab; **Ctrl/Cmd+click** on a one-pane tab or a `+` item brings it into the tab on screen, in the same order as a join below (a full tab — four panes — opens it as a tab of its own instead), and a one-pane tab dragged onto a pane joins that tab there. Any tab drags along the bar to reorder it; a pane's header dragged onto the bar takes that pane out into a tab of its own at that place. Dropped on the MIDDLE of another tab (it highlights), a tab — or a pane's header — joins that tab instead, in a fixed order: the second pane goes to the right (left | right), the third under the right pane, the fourth under the left pane (2×2) — the new pane always splits the BIGGEST one by the tree's shape (dragged dividers ignored, a tie goes to the later pane), so a three-pane tab of any shape fills its one missing quarter; a drop that would pass four panes reorders instead (`joinGroup`). A pane drag (a pane's header, or a one-pane tab) held over another tab for 0.6 s opens that tab (`SPRING_MS`), so the pane can be dropped on any edge of that tab's panes instead of the fixed join order; on a bar wider than the screen, dragging near either end scrolls it. Every live session stays mounted and those of other tabs are hidden with CSS, so switching tabs never drops a terminal. A tab's **×** stops every session in it (`DELETE /api/run/<runId>`, after a confirm when one is an AI tool); with no tab left it moves to the home. The **+** menu opens a new tab in the first free slot, which the server picks (`slot: "auto"`, under the workspace lock): **New terminal** (your own default shell in the workspace folder — `POST /api/lanes/:id/start {slot, shell: true}`, recorded as tool `shell`; it is not an agent, so the workspace's `run_id`/`status` are left alone), each installed, enabled AI tool (started bare, on its default model, no prompt), and **Agent settings…** (Settings → Agents). With ten sessions open **+** is disabled. EVERY tab is bound to the sidebar's selection — unless Settings → Appearance → **Tabs from every workspace** is on (`agenthub.workspace.allTabs`, `client/src/lib/workspaceTabs.ts`, synced to the account): then the bar holds every workspace's live sessions, each tab named `<workspace> · <session>`, a combo may mix workspaces, and focusing a pane selects its workspace (`?lane=` follows `?run=`, so the Changes panel and the sidebar follow too). Those tabs are stored once, under `all` in `agenthub.workspace.paneLayout`, their leaves `paneKey`s (lane id × 10 + slot); a split's **New terminal** starts in the target pane's own workspace. A tab has no header of its own (no title, no **Active runs** list, no **New run**): other sessions are opened from the sidebar or the home's Recents. (The old fixed 1/2/4 split layout and its `agenthub.workspace.splitView` key are gone; **Split panes** replaces them.) **Settings** opened on a workspace — the sidebar's Settings, the `+` menu's Agent settings, any Settings link inside it — is one more tab at the bar's end (`?settings=<section>` beside `lane` and `run`, `client/src/lib/settingsLink.ts`): the Settings pages (`SettingsHub` with a `section`), shown instead of the panes, which stay mounted behind them, kept until the tab's X and reopened on the section it was on. Off a workspace a Settings link still opens the `/settings` page.
|
||||
|
||||
- **Split panes** — the console splits like VS Code (`client/src/components/run/PaneGrid.tsx`, model in `client/src/lib/paneLayout.ts`): one binary tree of splits PER TAB, each side by side or stacked at a ratio, whose leaves are the workspace's slots — at most four panes in a tab, a session never shown twice. **Ctrl/Cmd+click** a one-pane tab, a `+` item or a sidebar session row brings it into the tab on screen at the 2×2 grid's next place — the same order as a tab join, whichever pane is focused (the intent rides on the navigation's state, never in the URL); with more than one pane each gets a slim header: **drag** it (or a tab) onto another pane — its edge splits there, its middle swaps the two (a session from another tab takes the pane's place and that one moves to a tab of its own) —; while the drag lasts the moved pane is dimmed, the drag shows a small chip with its name, and the drop preview says what will happen (*Place on the left*, *Swap places*…, `dropAction`), its **▾** shows another live session in it (swapping with the pane that showed it), **Split right / Split down** puts a not-shown session or a **New terminal** beside it, its **⤡** takes the pane out of this tab into a tab of its own (its session keeps running), and its **×** (also in the header's right-click menu) stops just that pane's session — the same `DELETE /api/run/<runId>` and AI-tool confirm as a tab's **×** — without taking it out first. A session dropped or swapped in from another tab leaves that tab. Dividers drag (or move by arrow keys) between 10% and 90%. Clicking in (or tabbing into) a pane focuses it, `?run=` follows — so the right panel, Analyst and the sidebar read the focused session — and the keyboard goes with it: only the focused pane's terminal takes it when it appears. A pane closes when its terminal reports that its session ended (exited, closed from another browser); the run list itself only prunes a remembered layout on a workspace's first read, and a pane opened here whose session never got listed — one empty read, which the server returns on any tmux error, never wipes a split. A session ending while the home or a replay is shown only closes its pane; the URL stays. The tabs and their layouts are a viewer preference kept per workspace in `localStorage` (`agenthub.workspace.paneLayout`, `{groups, active}`; a stored pre-tab single layout reads as one tab), never on the server. Every pane is an absolutely placed sibling keyed by its slot, so splitting, swapping or resizing never re-parents — and so never remounts — a live terminal; the session view itself fills the page edge to edge, its tab bar and the right panel's activity bar one header strip.
|
||||
- **Preview** — the session view's right panel has two tabs, **Changes** and **Preview** (`client/src/components/lanes/UrlPreview.tsx`). Preview shows a web page by address — before one is open, the workspace's bound ports are offered as `localhost:<port>` chips — with Reload and **Open in a browser tab**; the last address is remembered per workspace in this browser (`agenthub.preview.url.<id>`). Every link follows one rule (`client/src/lib/openInPreview.ts`): a chat link, bare URL or terminal link to `localhost`/`127.0.0.1`/`0.0.0.0`/`[::1]` opens in Preview while a session view is open and the dashboard itself is opened on this machine; any other link, and any Ctrl/Cmd click, opens a browser tab. The page runs on its own origin (the dashboard's own is refused) with no top navigation. A site that forbids embedding — YouTube, Google, most login pages — shows blank or a broken-page icon; any page not on this machine gets a strip under the address bar saying so, with **Open tab** beside it.
|
||||
- **Workspace grid** — one card per workspace, 1 column, 2 at `md`, 3 at `xl`. Each card carries the workspace id, liveness dot and status, title, declared stage and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /api/lanes/:id/git`, and the action row. The needs-you message (`needs_action`) is on the workspace list tile only — the detail card no longer repeats it as a banner.
|
||||
|
||||
Run history is per workspace, queryable via `GET /api/run/history?laneId=<n>`.
|
||||
|
||||
+4
-2
@@ -188,7 +188,9 @@ Workspaces made before server mode, or by the host account, have none.
|
||||
load, memory and swap, every real disk (HDD or SSD, how full; the one
|
||||
holding the users' homes is marked), any NVIDIA GPU. Each user's live
|
||||
share of it is a **Users · CPU / RAM now** card on the same tab (a tile per
|
||||
user, their busiest processes on click; their home share stays in
|
||||
user, their busiest processes on click; on hover, for an
|
||||
admin or analyst — from `GET /api/users` — their role, $ spent today,
|
||||
workspaces, sessions and last sign-in; their home share stays in
|
||||
Administration's users table), and the processes of no user are listed apart — CPU % of the whole machine (100 % = every core busy) and
|
||||
resident memory over a 1 s `/proc` sample, a process count, the busiest
|
||||
processes by their kernel name (never argv or env), and their home's size
|
||||
@@ -886,7 +888,7 @@ What runs as the member, and how:
|
||||
- In a member's folder, git and `gh` run as `sudo -n -u ah-<name> git …`, so git's own ownership check passes and every file stays the member's.
|
||||
- The editor and `.gitignore` saves go through `/opt/agenthub/libexec/write-as`.
|
||||
- A file uploaded in the Files tab (`POST /api/lanes/:id/git/upload`) is written the same way, AS the member through `write-as` (content on stdin), and only as a NEW file in an existing folder of the workspace — never over an existing name, never into `.git`, never a home dot folder, at most 25 MB. With **Download** on a file (`GET /api/lanes/:id/git/download`, read like any member file below, up to 512 MB) this is how a member with no SSH or `scp` to the host moves files in and out of their own folder; both work in a folder that is not a git repo.
|
||||
- AgentHub's stored git logins are never handed to a member's git, and a password typed in the git bar goes into the member's OWN store (below) for that one call — through `write-as`'s stdin, never sudo's argv or env — and out again unless "remember" was ticked; a store that cannot be read is never rewritten (`409 ESTOREUNREADABLE`). The member keeps their OWN identity in Settings → Git → **Your git identity** (`/api/me/git`): a commit author (`user.name`/`user.email` in their `~/.gitconfig` — commits fail without one; a new member has none), SSH keys AgentHub makes for them (an ED25519 and/or an RSA one — AWS CodeCommit takes only RSA) and HTTPS logins, both under `~/.config/agenthub/` in their home, written as them by `write-as` (0600). Add workspace → **Clone** (`POST /api/lanes/clone`) clones as them with it and writes it into the clone's own config (`core.sshCommand` / `credential.helper=store --file=…`), so the git bar and their agents' pushes in that repo use it too. A plain `git` they type themselves in a pane reads none of that, so making the key also sets `core.sshCommand` in their `~/.gitconfig` (the key first, then `~/.ssh/id_*`; a switch under the key, issue #82) — unless they already set their own, which is left alone. The SSH keys are shown one TAB per kind of host, each with only its own steps: **GitHub · GitLab** (ED25519, for Gitea and Bitbucket too) and **AWS CodeCommit** (RSA: copy → upload in IAM → paste the SSH key ID `APKA…` — saved for every region at once, no region to pick), then **Other hosts** (advanced); a tab's **Add another key** makes one more key of that kind at once (named `aws-2`, `aws-3`… / `git-2`…) as its own card below, with the same steps (an AWS one's step 3 saves its key ID under the key's name, tied to that key — a clone of the plain AWS URL tries it; a GitHub one's ties the key to its host — clone `ssh://<name>/<org>/<repo>.git`); it opens on the AWS tab when only an RSA key exists. The terminal switch sits under the tabs. The key ID (or another host's user) is saved as `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` in the same `~/.gitconfig`, so the plain `git clone ssh://git-codecommit.<region>.amazonaws.com/v1/repos/<repo>` works in a pane and in Clone — and Clone tries every CodeCommit login saved (the region's own first, then each other account's key — an IAM key ID works in every region of its AWS account), keeping the one that logs in in the clone's `origin`. A login typed in the Clone dialog goes to their store through `write-as`'s stdin, never sudo's argv or env, and is removed again after the clone unless "remember" was ticked.
|
||||
- AgentHub's stored git logins are never handed to a member's git, and a password typed in the git bar goes into the member's OWN store (below) for that one call — through `write-as`'s stdin, never sudo's argv or env — and out again unless "remember" was ticked; a store that cannot be read is never rewritten (`409 ESTOREUNREADABLE`). The member keeps their OWN identity in Settings → Git → **Your git identity** (`/api/me/git`): a commit author (`user.name`/`user.email` in their `~/.gitconfig` — commits fail without one; a new member has none), SSH keys AgentHub makes for them (an ED25519 and/or an RSA one — AWS CodeCommit takes only RSA) and HTTPS logins, both under `~/.config/agenthub/` in their home, written as them by `write-as` (0600). Add workspace → **Clone** (`POST /api/lanes/clone`) clones as them with it and writes it into the clone's own config (`core.sshCommand` / `credential.helper=store --file=…`), so the git bar and their agents' pushes in that repo use it too. A plain `git` they type themselves in a pane reads none of that, so making (or importing) a key also sets `core.sshCommand` in their `~/.gitconfig` (the key first, then `~/.ssh/id_*`; a switch under the key, issue #82) — unless they already set their own, which is left alone. **Commit signing** sits under the author: one picker signs every commit (terminal, Changes panel, agents) with one of their keys — `gpg.format ssh`, `user.signingkey` = that key's private file, `commit.gpgsign true` in their `~/.gitconfig`; a signing key they set up themselves is replaced only after a confirm — and **Import a key you have** takes a key file or paste (OpenSSH or PEM, ed25519 or RSA ≥ 2048, no passphrase: a dashboard commit has nowhere to type one) into the same `~/.config/agenthub/ssh/` as a named key (`signing`, `signing-2`…), written as them, and by default signs with it at once; only its public half is shown again. The SSH keys are shown one TAB per kind of host, each with only its own steps: **Git (GitHub, GitLab, Gitea, Backlog…)** (ED25519, for Bitbucket too) and **AWS CodeCommit** (RSA: copy → upload in IAM → paste the SSH key ID `APKA…` — saved for every region at once, no region to pick), then **Other hosts** (advanced); a tab's **Add another key** makes one more key of that kind at once (named `aws-2`, `aws-3`… / `git-2`…) as its own card below, with the same steps (an AWS one's step 3 saves its key ID under the key's name, tied to that key — a clone of the plain AWS URL tries it; a GitHub one's ties the key to its host — clone `ssh://<name>/<org>/<repo>.git`); it opens on the AWS tab when only an RSA key exists. The terminal switch sits under the tabs. The key ID (or another host's user) is saved as `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` in the same `~/.gitconfig`, so the plain `git clone ssh://git-codecommit.<region>.amazonaws.com/v1/repos/<repo>` works in a pane and in Clone — and Clone tries every CodeCommit login saved (the region's own first, then each other account's key — an IAM key ID works in every region of its AWS account), keeping the one that logs in in the clone's `origin`. A login typed in the Clone dialog goes to their store through `write-as`'s stdin, never sudo's argv or env, and is removed again after the clone unless "remember" was ticked.
|
||||
- **Files a pane reads.** These are written into `~/.agenthub/handoff/` AS the member (`write-as`). The dashboard never writes, changes or deletes anything in a member's home itself: the member could have swapped the name for a link. The files are:
|
||||
- the workspace prompt and its context files;
|
||||
- the AI Proxy settings: no key, only that pane's own token for the gateway;
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.56",
|
||||
"version": "2.8.65",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "2.8.56",
|
||||
"version": "2.8.65",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.56",
|
||||
"version": "2.8.65",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* @file chat.test.js
|
||||
* @description Chat between users (server/lib/chat.js, server/routes/chat.js):
|
||||
* a direct chat is one per pair, a group's creator alone changes it, only a
|
||||
* room's members read it — an admin included —, unread counts and read marks,
|
||||
* files served only to members under a sandbox CSP, local mode refused, and
|
||||
* every chat frame reaches exactly the users in its `to` (scope.exactAudience).
|
||||
* @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(), "chat-test-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.DASHBOARD_DATA_DIR = TMP;
|
||||
const prevMode = process.env.AGENTHUB_MODE;
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
|
||||
const { db } = require("../db");
|
||||
const scope = require("../lib/scope");
|
||||
|
||||
const USERS = {
|
||||
1: { id: 1, username: "boss", role: "admin" },
|
||||
2: { id: 2, username: "an", role: "developer" },
|
||||
3: { id: 3, username: "binh", role: "developer" },
|
||||
4: { id: 4, username: "chi", role: "analyst" },
|
||||
};
|
||||
let srv;
|
||||
let base;
|
||||
|
||||
before(async () => {
|
||||
const ins = db.prepare(
|
||||
"INSERT INTO users (id, username, role, password_hash) VALUES (?, ?, ?, 'x')"
|
||||
);
|
||||
for (const u of Object.values(USERS)) ins.run(u.id, u.username, u.role);
|
||||
const express = require("express");
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.user = USERS[req.headers["x-user"]];
|
||||
next();
|
||||
});
|
||||
app.use("/api/chat", require("../routes/chat"));
|
||||
await new Promise((r) => (srv = app.listen(0, r)));
|
||||
base = `http://127.0.0.1:${srv.address().port}/api/chat`;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
srv?.close();
|
||||
if (prevMode === undefined) delete process.env.AGENTHUB_MODE;
|
||||
else process.env.AGENTHUB_MODE = prevMode;
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function call(user, method, p, body) {
|
||||
const init = { method, headers: { "x-user": String(user) } };
|
||||
if (body instanceof FormData) init.body = body;
|
||||
else if (body !== undefined) {
|
||||
init.body = JSON.stringify(body);
|
||||
init.headers["content-type"] = "application/json";
|
||||
}
|
||||
const r = await fetch(base + p, init);
|
||||
const type = r.headers.get("content-type") || "";
|
||||
return {
|
||||
status: r.status,
|
||||
headers: r.headers,
|
||||
body: type.includes("json") ? await r.json() : await r.text(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("chat", () => {
|
||||
let dm;
|
||||
let group;
|
||||
|
||||
it("lists the other enabled users to chat with", async () => {
|
||||
const r = await call(2, "GET", "/people");
|
||||
assert.deepEqual(r.body.people.map((p) => p.username).sort(), ["binh", "boss", "chi"]);
|
||||
});
|
||||
|
||||
it("opens one direct chat per pair, whoever starts it", async () => {
|
||||
const a = await call(2, "POST", "/rooms", { kind: "dm", userId: 3 });
|
||||
assert.equal(a.status, 201);
|
||||
const b = await call(3, "POST", "/rooms", { kind: "dm", userId: 2 });
|
||||
assert.equal(b.status, 200);
|
||||
assert.equal(b.body.room.id, a.body.room.id);
|
||||
dm = a.body.room.id;
|
||||
assert.equal((await call(2, "POST", "/rooms", { kind: "dm", userId: 2 })).status, 400);
|
||||
});
|
||||
|
||||
it("keeps a chat to its members — an admin gets 404 like a missing room", async () => {
|
||||
const m = await call(2, "POST", `/rooms/${dm}/messages`, { body: "chào Bình" });
|
||||
assert.equal(m.status, 201);
|
||||
assert.equal((await call(1, "GET", `/rooms/${dm}/messages`)).status, 404);
|
||||
assert.equal((await call(1, "POST", `/rooms/${dm}/messages`, { body: "x" })).status, 404);
|
||||
assert.deepEqual((await call(1, "GET", "/rooms")).body.rooms, []);
|
||||
const seen = await call(3, "GET", `/rooms/${dm}/messages`);
|
||||
assert.deepEqual(
|
||||
seen.body.messages.map((x) => x.body),
|
||||
["chào Bình"]
|
||||
);
|
||||
});
|
||||
|
||||
it("counts unread messages until they are marked read", async () => {
|
||||
let rooms = (await call(3, "GET", "/rooms")).body.rooms;
|
||||
assert.equal(rooms[0].unread, 1);
|
||||
assert.equal((await call(2, "GET", "/rooms")).body.rooms[0].unread, 0); // your own
|
||||
await call(3, "POST", `/rooms/${dm}/read`, { upTo: rooms[0].lastMessage.id });
|
||||
rooms = (await call(3, "GET", "/rooms")).body.rooms;
|
||||
assert.equal(rooms[0].unread, 0);
|
||||
});
|
||||
|
||||
it("lets a group's creator alone change it, and any member leave", async () => {
|
||||
const g = await call(2, "POST", "/rooms", { kind: "group", name: "Team", userIds: [3] });
|
||||
assert.equal(g.status, 201);
|
||||
group = g.body.room.id;
|
||||
assert.deepEqual(g.body.room.members, [2, 3]);
|
||||
assert.equal((await call(3, "PATCH", `/rooms/${group}`, { add: [4] })).status, 403);
|
||||
const up = await call(2, "PATCH", `/rooms/${group}`, { add: [4], name: "Team A" });
|
||||
assert.deepEqual(up.body.room.members, [2, 3, 4]);
|
||||
assert.equal(up.body.room.name, "Team A");
|
||||
assert.equal((await call(4, "POST", `/rooms/${group}/leave`)).status, 200);
|
||||
assert.equal((await call(4, "GET", `/rooms/${group}/messages`)).status, 404);
|
||||
assert.equal((await call(2, "PATCH", `/rooms/${group}`, { add: [99] })).status, 400);
|
||||
});
|
||||
|
||||
it("stores files and serves them only to members, sandboxed", async () => {
|
||||
const fd = new FormData();
|
||||
fd.append("body", "ảnh nè");
|
||||
fd.append("files", new Blob([Buffer.from("PNGDATA")], { type: "image/png" }), "màn hình.png");
|
||||
fd.append("files", new Blob(["<script>x</script>"], { type: "text/html" }), "a.html");
|
||||
const m = await call(3, "POST", `/rooms/${group}/messages`, fd);
|
||||
assert.equal(m.status, 201);
|
||||
const [img, page] = m.body.message.files;
|
||||
assert.equal(img.name, "màn hình.png");
|
||||
const got = await call(2, "GET", `/rooms/${group}/files/${img.id}`);
|
||||
assert.equal(got.status, 200);
|
||||
assert.equal(got.body, "PNGDATA");
|
||||
assert.match(got.headers.get("content-security-policy"), /sandbox/);
|
||||
assert.equal(got.headers.get("x-content-type-options"), "nosniff");
|
||||
assert.match(got.headers.get("content-disposition"), /^inline/);
|
||||
const html = await call(2, "GET", `/rooms/${group}/files/${page.id}`);
|
||||
assert.match(html.headers.get("content-disposition"), /^attachment/);
|
||||
assert.equal((await call(1, "GET", `/rooms/${group}/files/${img.id}`)).status, 404);
|
||||
assert.equal(
|
||||
(await call(2, "GET", `/rooms/${group}/files/..%2f..%2fdashboard.db`)).status,
|
||||
404
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses an empty message", async () => {
|
||||
assert.equal((await call(2, "POST", `/rooms/${dm}/messages`, { body: " " })).status, 400);
|
||||
});
|
||||
|
||||
it("is refused in local mode", async () => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
try {
|
||||
assert.equal((await call(2, "GET", "/rooms")).body.error.code, "ELOCALMODE");
|
||||
} finally {
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
}
|
||||
});
|
||||
|
||||
it("sends a chat frame to exactly its `to`, an admin not included", () => {
|
||||
const only = scope.exactAudience("chat_message", { roomId: 1, to: [2, 3] });
|
||||
assert.deepEqual([...only], [2, 3]);
|
||||
assert.deepEqual([...scope.exactAudience("chat_room", {})], []);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,9 @@
|
||||
* place, across workspaces), delete into the trash and restore/purge, a
|
||||
* streamed upload into new sub-folders with every conflict policy, a batch
|
||||
* of many small files in one request (and, in a member's folder, as ONE
|
||||
* write-as `extract` of a tar that real `tar` unpacks), a zip of
|
||||
* write-as `extract` of a tar that real `tar` unpacks), a resumable upload
|
||||
* sent in parts and committed from the staged bytes (and a member's upload
|
||||
* written to a hidden temp file, moved onto its name only when whole), a zip of
|
||||
* files and folders that unzips back to the same bytes — and the guards: no
|
||||
* `..`, no symlink walking out, nothing in `.git`, no secret file, the
|
||||
* workspace folder itself never moved or deleted, cross-origin refused.
|
||||
@@ -293,6 +295,144 @@ describe("File Explorer", () => {
|
||||
assert.ok(!fs.existsSync(path.join(W1, "up/s.txt")));
|
||||
});
|
||||
|
||||
it("resumes an upload sent in parts, and writes it only once every byte is staged", async () => {
|
||||
const staging = require("../lib/upload-staging");
|
||||
const id = "resume-test-0000000001";
|
||||
const data = Buffer.from("0123456789abcdefghij");
|
||||
const part = (offset, bytes, upload = id) =>
|
||||
request(
|
||||
"POST",
|
||||
`/api/lanes/${lane1}/fs/upload-part?upload=${upload}&offset=${offset}&total=${data.length}`,
|
||||
bytes,
|
||||
{ "Content-Type": "application/octet-stream" }
|
||||
);
|
||||
const status = async () =>
|
||||
(await request("GET", `/api/lanes/${lane1}/fs/upload-part?upload=${id}`)).body;
|
||||
assert.deepEqual(await status(), { received: 0, total: null, done: false });
|
||||
assert.equal((await part(0, data.subarray(0, 8))).body.received, 8);
|
||||
// A part that does not start where the staged bytes end: 409 with the truth.
|
||||
const off = await part(4, data.subarray(4, 12));
|
||||
assert.equal(off.status, 409);
|
||||
assert.equal(off.body.error.code, "EOFFSET");
|
||||
assert.equal(off.body.error.received, 8);
|
||||
// Committing early writes nothing.
|
||||
const early = await request(
|
||||
"POST",
|
||||
`/api/lanes/${lane1}/fs/upload?path=res/file.bin&staged=${id}`
|
||||
);
|
||||
assert.equal(early.status, 409);
|
||||
assert.equal(early.body.error.code, "EINCOMPLETE");
|
||||
assert.ok(!fs.existsSync(path.join(W1, "res/file.bin")));
|
||||
// A part past the end is refused.
|
||||
assert.equal((await part(8, Buffer.alloc(13))).status, 413);
|
||||
assert.equal((await status()).received, 8);
|
||||
assert.equal((await part(8, data.subarray(8))).body.received, data.length);
|
||||
const commit = () =>
|
||||
request("POST", `/api/lanes/${lane1}/fs/upload?path=res/file.bin&staged=${id}`);
|
||||
const done = await commit();
|
||||
assert.equal(done.status, 201);
|
||||
assert.deepEqual(done.body, { path: "res/file.bin" });
|
||||
assert.deepEqual(fs.readFileSync(path.join(W1, "res/file.bin")), data);
|
||||
// A commit whose answer was lost is answered again, never written twice.
|
||||
fs.writeFileSync(path.join(W1, "res/file.bin"), "changed since");
|
||||
const again = await commit();
|
||||
assert.equal(again.status, 201);
|
||||
assert.deepEqual(again.body, { path: "res/file.bin" });
|
||||
assert.equal(fs.readFileSync(path.join(W1, "res/file.bin"), "utf8"), "changed since");
|
||||
assert.deepEqual(await status(), { received: data.length, total: data.length, done: true });
|
||||
// A taken name keeps the staged bytes for a retry with another policy.
|
||||
const id2 = "resume-test-0000000002";
|
||||
await part(0, data, id2);
|
||||
const taken = await request(
|
||||
"POST",
|
||||
`/api/lanes/${lane1}/fs/upload?path=res/file.bin&staged=${id2}`
|
||||
);
|
||||
assert.equal(taken.status, 409);
|
||||
assert.equal(taken.body.error.code, "EEXISTS");
|
||||
const kept = await request(
|
||||
"POST",
|
||||
`/api/lanes/${lane1}/fs/upload?path=res/file.bin&staged=${id2}&conflict=keep`
|
||||
);
|
||||
assert.equal(kept.body.path, "res/file (1).bin");
|
||||
assert.deepEqual(fs.readFileSync(path.join(W1, "res/file (1).bin")), data);
|
||||
// A batch goes the same way.
|
||||
const head = Buffer.from(JSON.stringify({ files: [{ path: "b1.txt", size: 2 }] }));
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(head.length);
|
||||
const batch = Buffer.concat([len, head, Buffer.from("hi")]);
|
||||
const id3 = "resume-test-0000000003";
|
||||
await request(
|
||||
"POST",
|
||||
`/api/lanes/${lane1}/fs/upload-part?upload=${id3}&offset=0&total=${batch.length}`,
|
||||
batch,
|
||||
{ "Content-Type": "application/octet-stream" }
|
||||
);
|
||||
const b = await request("POST", `/api/lanes/${lane1}/fs/upload-batch?dir=res&staged=${id3}`);
|
||||
assert.equal(b.status, 201);
|
||||
assert.equal(fs.readFileSync(path.join(W1, "res/b1.txt"), "utf8"), "hi");
|
||||
// Stop: forgotten. A bad id or an unknown upload is refused, never a path.
|
||||
assert.equal(
|
||||
(await request("DELETE", `/api/lanes/${lane1}/fs/upload-part?upload=${id}`)).status,
|
||||
200
|
||||
);
|
||||
assert.equal((await status()).received, 0);
|
||||
assert.equal(
|
||||
(await request("GET", `/api/lanes/${lane1}/fs/upload-part?upload=../../etc`)).status,
|
||||
400
|
||||
);
|
||||
assert.equal(
|
||||
(await request("POST", `/api/lanes/${lane1}/fs/upload?path=x&staged=nope-nope-nope-nope`))
|
||||
.status,
|
||||
404
|
||||
);
|
||||
// Uploads are per user: another user's key never sees this one.
|
||||
assert.equal(staging.status("42", id3).received, 0);
|
||||
assert.ok(fs.statSync(path.join(staging.root(), "local")).isDirectory());
|
||||
for (const i of [id2, id3]) staging.drop("local", i);
|
||||
});
|
||||
|
||||
it("keeps the bytes of a part cut short, caps unfinished uploads, sweeps old ones", async () => {
|
||||
const staging = require("../lib/upload-staging");
|
||||
const { Readable } = require("node:stream");
|
||||
const id = "cut-short-0000000001";
|
||||
let sent = false;
|
||||
const cut = new Readable({
|
||||
read() {
|
||||
if (!sent) {
|
||||
sent = true;
|
||||
this.push(Buffer.from("abcd"));
|
||||
} else setTimeout(() => this.destroy(new Error("connection reset")), 30);
|
||||
},
|
||||
});
|
||||
await assert.rejects(staging.putPart("u1", id, { offset: 0, total: 10, stream: cut }));
|
||||
assert.equal(staging.status("u1", id).received, 4);
|
||||
const rest = Readable.from([Buffer.from("efghij")]);
|
||||
assert.equal(
|
||||
(await staging.putPart("u1", id, { offset: 4, total: 10, stream: rest })).received,
|
||||
10
|
||||
);
|
||||
// At most MAX_OPEN unfinished uploads per user.
|
||||
for (let i = 1; i < staging.MAX_OPEN; i++) {
|
||||
await staging.putPart("u1", `cap-test-${String(i).padStart(10, "0")}`, {
|
||||
offset: 0,
|
||||
total: 5,
|
||||
stream: Readable.from([Buffer.from("a")]),
|
||||
});
|
||||
}
|
||||
await assert.rejects(
|
||||
staging.putPart("u1", "cap-test-overflow-01", {
|
||||
offset: 0,
|
||||
total: 5,
|
||||
stream: Readable.from([Buffer.from("a")]),
|
||||
}),
|
||||
{ code: "ETOOMANY" }
|
||||
);
|
||||
// A day untouched: swept.
|
||||
staging.sweep(Date.now() + staging.KEEP_MS + 60_000);
|
||||
assert.equal(staging.status("u1", id).received, 0);
|
||||
assert.deepEqual(fs.readdirSync(path.join(staging.root(), "u1")), []);
|
||||
});
|
||||
|
||||
it("zips files and folders into one download that unzips to the same bytes", async () => {
|
||||
const r = await request("GET", `/api/lanes/${lane1}/fs/zip?p=src&p=site/css/app.css`);
|
||||
assert.equal(r.status, 200);
|
||||
@@ -404,6 +544,49 @@ describe("File Explorer in a member's folder (S3, run-as stubbed)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("writes an upload to a hidden temp file and moves it onto the name only when complete", async () => {
|
||||
const { Readable } = require("node:stream");
|
||||
const prev = runAs.writeAsAsync;
|
||||
const runs = [];
|
||||
// write-as as a shell would run it: `cat >file` takes whatever arrived.
|
||||
runAs.writeAsAsync = async (_a, file, args, { input } = {}) => {
|
||||
runs.push([path.basename(file), ...args.map((a) => path.basename(a))]);
|
||||
if (!args.length) {
|
||||
const chunks = [];
|
||||
for await (const c of input) chunks.push(c);
|
||||
fs.writeFileSync(file, Buffer.concat(chunks));
|
||||
} else if (args[0] === "move") fs.renameSync(file, args[1]);
|
||||
else if (args[0] === "remove") fs.rmSync(file, { force: true });
|
||||
};
|
||||
try {
|
||||
const out = await fx.upload(M, "up.txt", Readable.from([Buffer.from("whole")]), {
|
||||
length: "5",
|
||||
});
|
||||
assert.equal(out.path, "up.txt");
|
||||
assert.equal(fs.readFileSync(path.join(M, "up.txt"), "utf8"), "whole");
|
||||
assert.match(runs[0][0], /^\.up\.txt\.agenthub-upload-/);
|
||||
assert.deepEqual(runs[1].slice(1), ["move", "up.txt"]);
|
||||
// The body ended early (a dropped connection, a dying server): nothing
|
||||
// under the real name, and the temp file is removed.
|
||||
runs.length = 0;
|
||||
await assert.rejects(
|
||||
fx.upload(M, "cut.txt", Readable.from([Buffer.from("par")]), { length: "10" }),
|
||||
{ code: "EBADBODY" }
|
||||
);
|
||||
assert.ok(!fs.existsSync(path.join(M, "cut.txt")));
|
||||
assert.deepEqual(
|
||||
fs.readdirSync(M).filter((n) => n.includes("agenthub-upload")),
|
||||
[]
|
||||
);
|
||||
assert.deepEqual(
|
||||
runs.map((r) => r[1] ?? "write"),
|
||||
["write", "remove"]
|
||||
);
|
||||
} finally {
|
||||
runAs.writeAsAsync = prev;
|
||||
}
|
||||
});
|
||||
|
||||
it("says the helper needs updating instead of failing obscurely", async () => {
|
||||
runAs.writeAsCanMove = () => false;
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* @file Tests for importing an SSH key the user already has and signing
|
||||
* commits with it (`POST /api/me/git/ssh-key/import`, `PUT /api/me/git/signing`,
|
||||
* `server/lib/git-identity.js`): OpenSSH and PEM keys of both types read into
|
||||
* the same public half `ssh-keygen -y` prints, only that half returned, the
|
||||
* private file 0600; a passphrase key, a `.pub`, an ecdsa key and the same key
|
||||
* twice refused; a real commit signed and verified by git; a signing key the
|
||||
* user set themselves kept unless replaced, and taken out (its file kept) on
|
||||
* `clearForeign`; deleting the key stops signing. Per workspace
|
||||
* (`PUT /api/me/git/signing/lane`): only that workspace's commits — and its
|
||||
* sessions' `ah-<id>-*` worktrees — are signed, never another repo's, the
|
||||
* user's own includes stay, and deleting the key stops that workspace's too.
|
||||
* @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 crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const http = require("node:http");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "git-signing-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, ".claude");
|
||||
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
process.env.GIT_CONFIG_NOSYSTEM = "1";
|
||||
process.env.HOME = ROOT;
|
||||
|
||||
const { createApp } = require("../index");
|
||||
|
||||
let app;
|
||||
let base;
|
||||
const KEYS = path.join(ROOT, "keys");
|
||||
|
||||
function request(method, p, body) {
|
||||
return fetch(`${base}${p}`, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
|
||||
}
|
||||
|
||||
/** A key made by ssh-keygen itself: `{text, pub}` (pub = type + base64). */
|
||||
function keygen(name, args, pass = "") {
|
||||
const file = path.join(KEYS, name);
|
||||
execFileSync("ssh-keygen", ["-q", ...args, "-N", pass, "-C", "me@x", "-f", file]);
|
||||
const pub = fs.readFileSync(`${file}.pub`, "utf8").split(" ").slice(0, 2).join(" ");
|
||||
return { text: fs.readFileSync(file, "utf8"), pub };
|
||||
}
|
||||
const pubOf = (line) => line.split(" ").slice(0, 2).join(" ");
|
||||
const gitGet = (k) => {
|
||||
try {
|
||||
return execFileSync("git", ["config", "--global", "--get", k], { encoding: "utf8" }).trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
const removeAll = async () => {
|
||||
await request("PUT", "/api/me/git/signing", { key: null });
|
||||
await request("DELETE", "/api/me/git/ssh-key");
|
||||
};
|
||||
|
||||
before(async () => {
|
||||
fs.mkdirSync(KEYS);
|
||||
app = http.createServer(createApp());
|
||||
await new Promise((r) => app.listen(0, "127.0.0.1", r));
|
||||
base = `http://127.0.0.1:${app.address().port}`;
|
||||
});
|
||||
after(async () => {
|
||||
await new Promise((r) => app.close(r));
|
||||
fs.rmSync(ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("POST /api/me/git/ssh-key/import", () => {
|
||||
it("reads an OpenSSH ed25519 key into the public half ssh-keygen prints", async () => {
|
||||
const k = keygen("ed", ["-t", "ed25519"]);
|
||||
const r = await request("POST", "/api/me/git/ssh-key/import", { privateKey: k.text });
|
||||
assert.equal(r.status, 201);
|
||||
assert.equal(pubOf(r.body.imported.publicKey), k.pub);
|
||||
assert.equal(r.body.imported.name, "signing");
|
||||
assert.equal(r.body.imported.id, "id_ed25519_signing");
|
||||
// Nothing private in the answer, and the file is the owner's alone.
|
||||
assert.ok(!JSON.stringify(r.body).includes("PRIVATE KEY"));
|
||||
const file = path.join(ROOT, ".claude", "agenthub-ssh", "id_ed25519_signing");
|
||||
assert.equal(fs.statSync(file).mode & 0o777, 0o600);
|
||||
// What was written is a key ssh-keygen itself reads back.
|
||||
assert.equal(
|
||||
pubOf(execFileSync("ssh-keygen", ["-y", "-f", file], { encoding: "utf8" }).trim()),
|
||||
k.pub
|
||||
);
|
||||
await removeAll();
|
||||
});
|
||||
|
||||
it("reads OpenSSH RSA and PEM (PKCS#8) RSA keys", async () => {
|
||||
const k = keygen("rsa", ["-t", "rsa", "-b", "2048"]);
|
||||
const a = await request("POST", "/api/me/git/ssh-key/import", { privateKey: k.text });
|
||||
assert.equal(a.status, 201);
|
||||
assert.equal(a.body.imported.id, "id_rsa_signing");
|
||||
assert.equal(pubOf(a.body.imported.publicKey), k.pub);
|
||||
const pem = crypto
|
||||
.generateKeyPairSync("rsa", { modulusLength: 2048 })
|
||||
.privateKey.export({ type: "pkcs8", format: "pem" });
|
||||
const b = await request("POST", "/api/me/git/ssh-key/import", {
|
||||
privateKey: pem,
|
||||
name: "work",
|
||||
});
|
||||
assert.equal(b.status, 201);
|
||||
assert.equal(b.body.imported.id, "id_rsa_work");
|
||||
await removeAll();
|
||||
});
|
||||
|
||||
it("refuses a passphrase key, a public key, an ecdsa key and the same key twice", async () => {
|
||||
const enc = keygen("enc", ["-t", "ed25519"], "secret-pass");
|
||||
const r1 = await request("POST", "/api/me/git/ssh-key/import", { privateKey: enc.text });
|
||||
assert.equal(r1.status, 400);
|
||||
assert.equal(r1.body.error.code, "EKEYPASSPHRASE");
|
||||
const r2 = await request("POST", "/api/me/git/ssh-key/import", { privateKey: enc.pub });
|
||||
assert.equal(r2.body.error.code, "EBADKEY");
|
||||
assert.match(r2.body.error.message, /PUBLIC key/);
|
||||
const ec = keygen("ec", ["-t", "ecdsa"]);
|
||||
const r3 = await request("POST", "/api/me/git/ssh-key/import", { privateKey: ec.text });
|
||||
assert.equal(r3.body.error.code, "EBADKEYTYPE");
|
||||
const k = keygen("twice", ["-t", "ed25519"]);
|
||||
assert.equal(
|
||||
(await request("POST", "/api/me/git/ssh-key/import", { privateKey: k.text })).status,
|
||||
201
|
||||
);
|
||||
const again = await request("POST", "/api/me/git/ssh-key/import", { privateKey: k.text });
|
||||
assert.equal(again.status, 409);
|
||||
assert.equal(again.body.error.code, "EEXISTS");
|
||||
// A mangled paste never reaches the disk.
|
||||
const lines = k.text.split("\n");
|
||||
const i = 3; // inside the private section
|
||||
lines[i] = lines[i].slice(0, 20) + (lines[i][20] === "Q" ? "R" : "Q") + lines[i].slice(21);
|
||||
const bad = await request("POST", "/api/me/git/ssh-key/import", {
|
||||
privateKey: lines.join("\n"),
|
||||
});
|
||||
assert.equal(bad.status, 400);
|
||||
await removeAll();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/me/git/signing", () => {
|
||||
it("signs a real commit with the imported key in one step", async () => {
|
||||
const k = keygen("sign", ["-t", "ed25519"]);
|
||||
const r = await request("POST", "/api/me/git/ssh-key/import", {
|
||||
privateKey: k.text,
|
||||
sign: true,
|
||||
});
|
||||
assert.equal(r.status, 201);
|
||||
assert.equal(r.body.signError, null);
|
||||
assert.deepEqual(r.body.signing, { state: "on", key: "id_ed25519_signing", path: null });
|
||||
assert.equal(gitGet("gpg.format"), "ssh");
|
||||
assert.equal(gitGet("commit.gpgsign"), "true");
|
||||
// The PRIVATE file: ssh-keygen signs with it, no agent needed.
|
||||
assert.match(gitGet("user.signingkey"), /agenthub-ssh\/id_ed25519_signing$/);
|
||||
|
||||
const repo = path.join(ROOT, "repo");
|
||||
execFileSync("git", ["init", "-q", repo]);
|
||||
const env = { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "me@x" };
|
||||
Object.assign(env, { GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "me@x" });
|
||||
execFileSync("git", ["-C", repo, "commit", "-q", "--allow-empty", "-m", "signed"], { env });
|
||||
const allowed = path.join(ROOT, "allowed");
|
||||
fs.writeFileSync(allowed, `me@x ${k.pub}\n`);
|
||||
const status = execFileSync(
|
||||
"git",
|
||||
["-C", repo, "-c", `gpg.ssh.allowedSignersFile=${allowed}`, "log", "-1", "--format=%G?"],
|
||||
{ encoding: "utf8" }
|
||||
).trim();
|
||||
assert.equal(status, "G");
|
||||
|
||||
// Deleting the signing key stops signing, so commits keep working.
|
||||
await request("DELETE", "/api/me/git/ssh-key?id=id_ed25519_signing");
|
||||
assert.equal(gitGet("user.signingkey"), "");
|
||||
assert.equal(gitGet("commit.gpgsign"), "");
|
||||
await removeAll();
|
||||
});
|
||||
|
||||
it("keeps a signing key the user set themselves unless told to replace it", async () => {
|
||||
execFileSync("git", ["config", "--global", "user.signingkey", "~/.ssh/id_ed25519"]);
|
||||
const k = keygen("mine", ["-t", "ed25519"]);
|
||||
const r = await request("POST", "/api/me/git/ssh-key/import", {
|
||||
privateKey: k.text,
|
||||
sign: true,
|
||||
});
|
||||
// The key is saved; only the switch was refused, and said so.
|
||||
assert.equal(r.status, 201);
|
||||
assert.equal(r.body.signError.code, "EFOREIGNSIGN");
|
||||
assert.deepEqual(r.body.signing, { state: "foreign", key: null, path: "~/.ssh/id_ed25519" });
|
||||
const off = await request("PUT", "/api/me/git/signing", { key: null });
|
||||
assert.equal(off.status, 200);
|
||||
assert.equal(gitGet("user.signingkey"), "~/.ssh/id_ed25519");
|
||||
const on = await request("PUT", "/api/me/git/signing", {
|
||||
key: r.body.imported.id,
|
||||
replace: true,
|
||||
});
|
||||
assert.equal(on.status, 200);
|
||||
assert.equal(on.body.signing.state, "on");
|
||||
const stop = await request("PUT", "/api/me/git/signing", { key: null });
|
||||
assert.equal(stop.body.signing.state, "off");
|
||||
assert.equal(gitGet("gpg.format"), "");
|
||||
const none = await request("PUT", "/api/me/git/signing", { key: "id_ed25519_nope" });
|
||||
assert.equal(none.body.error.code, "ENOKEY");
|
||||
await removeAll();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/me/git/signing/lane", () => {
|
||||
const env = {
|
||||
...process.env,
|
||||
GIT_AUTHOR_NAME: "t",
|
||||
GIT_AUTHOR_EMAIL: "me@x",
|
||||
GIT_COMMITTER_NAME: "t",
|
||||
GIT_COMMITTER_EMAIL: "me@x",
|
||||
};
|
||||
const commit = (dir, msg) =>
|
||||
execFileSync("git", ["-C", dir, "commit", "-q", "--allow-empty", "-m", msg], { env });
|
||||
/** `G` signed and verified, `N` not signed. */
|
||||
const sig = (dir, allowed) =>
|
||||
execFileSync(
|
||||
"git",
|
||||
["-C", dir, "-c", `gpg.ssh.allowedSignersFile=${allowed}`, "log", "-1", "--format=%G?"],
|
||||
{ encoding: "utf8" }
|
||||
).trim();
|
||||
const lane = async (dir) => {
|
||||
const r = await request("POST", "/api/lanes/ensure", { cwd: dir, exact: true });
|
||||
return r.body.lane.id;
|
||||
};
|
||||
|
||||
it("signs one workspace and its sessions' worktrees, never another repo", async () => {
|
||||
const A = path.join(ROOT, "ws-a");
|
||||
const B = path.join(ROOT, "ws-b");
|
||||
const plain = path.join(ROOT, "ws-plain");
|
||||
for (const d of [A, B]) {
|
||||
execFileSync("git", ["init", "-q", d]);
|
||||
commit(d, "first");
|
||||
}
|
||||
fs.mkdirSync(plain);
|
||||
const [a, b, p] = [await lane(A), await lane(B), await lane(plain)];
|
||||
// A user's own include stays whatever we do.
|
||||
execFileSync("git", ["config", "--global", "includeIf.gitdir:/elsewhere/.path", "/mine"]);
|
||||
const k = keygen("lane", ["-t", "ed25519"]);
|
||||
const imp = await request("POST", "/api/me/git/ssh-key/import", {
|
||||
privateKey: k.text,
|
||||
name: "lane",
|
||||
});
|
||||
const key = imp.body.imported.id;
|
||||
const allowed = path.join(ROOT, "allowed-lane");
|
||||
fs.writeFileSync(allowed, `me@x ${k.pub}\n`);
|
||||
|
||||
const on = await request("PUT", "/api/me/git/signing/lane", { laneId: a, key });
|
||||
assert.equal(on.status, 200);
|
||||
assert.deepEqual(on.body.laneSigning, [{ laneId: a, key }]);
|
||||
// Nothing account-wide: the global settings stay unset.
|
||||
assert.equal(on.body.signing.state, "off");
|
||||
assert.equal(gitGet("commit.gpgsign"), "");
|
||||
commit(A, "in a");
|
||||
commit(B, "in b");
|
||||
assert.equal(sig(A, allowed), "G");
|
||||
assert.equal(sig(B, allowed), "N");
|
||||
// A session's worktree of A is signed; any other worktree of A is not.
|
||||
const wt = path.join(A, ".claude", "worktrees", `ah-${a}-x1`);
|
||||
const other = path.join(ROOT, "other-wt");
|
||||
execFileSync("git", ["-C", A, "worktree", "add", "-q", "-b", "s1", wt]);
|
||||
execFileSync("git", ["-C", A, "worktree", "add", "-q", "-b", "s2", other]);
|
||||
commit(wt, "in session worktree");
|
||||
commit(other, "in other worktree");
|
||||
assert.equal(sig(wt, allowed), "G");
|
||||
assert.equal(sig(other, allowed), "N");
|
||||
// Nothing was written into the repository itself.
|
||||
assert.doesNotMatch(fs.readFileSync(path.join(A, ".git", "config"), "utf8"), /gpgsign/);
|
||||
|
||||
// Not a repo: refused; an unknown workspace: 404.
|
||||
const notRepo = await request("PUT", "/api/me/git/signing/lane", { laneId: p, key });
|
||||
assert.equal(notRepo.status, 409);
|
||||
assert.equal(notRepo.body.error.code, "ENOREPO");
|
||||
const gone = await request("PUT", "/api/me/git/signing/lane", { laneId: 9999, key });
|
||||
assert.equal(gone.status, 404);
|
||||
|
||||
// Off: unsigned again, our file gone, the user's include kept.
|
||||
const off = await request("PUT", "/api/me/git/signing/lane", { laneId: a, key: null });
|
||||
assert.deepEqual(off.body.laneSigning, []);
|
||||
commit(A, "after off");
|
||||
assert.equal(sig(A, allowed), "N");
|
||||
assert.equal(gitGet("includeIf.gitdir:/elsewhere/.path"), "/mine");
|
||||
|
||||
// A workspace removed from the dashboard while it signed can still be turned off.
|
||||
await request("PUT", "/api/me/git/signing/lane", { laneId: b, key });
|
||||
await request("POST", `/api/lanes/${b}/remove`, {});
|
||||
const orphan = await request("GET", "/api/me/git");
|
||||
assert.deepEqual(orphan.body.laneSigning, [{ laneId: b, key }]);
|
||||
const stopGone = await request("PUT", "/api/me/git/signing/lane", { laneId: b, key: null });
|
||||
assert.equal(stopGone.status, 200);
|
||||
assert.deepEqual(stopGone.body.laneSigning, []);
|
||||
const b2 = await lane(B);
|
||||
|
||||
// Deleting the key stops every workspace that signs with it.
|
||||
await request("PUT", "/api/me/git/signing/lane", { laneId: a, key });
|
||||
await request("PUT", "/api/me/git/signing/lane", { laneId: b2, key });
|
||||
const del = await request("DELETE", `/api/me/git/ssh-key?id=${key}`);
|
||||
assert.equal(del.status, 200);
|
||||
assert.deepEqual(del.body.laneSigning, []);
|
||||
commit(A, "after the key went");
|
||||
assert.equal(sig(A, allowed), "N");
|
||||
assert.equal(gitGet("includeIf.gitdir:/elsewhere/.path"), "/mine");
|
||||
assert.deepEqual(
|
||||
fs
|
||||
.readdirSync(path.join(ROOT, ".claude", "agenthub-signing"))
|
||||
.filter((n) => n.endsWith(".gitconfig")),
|
||||
[]
|
||||
);
|
||||
execFileSync("git", ["config", "--global", "--unset-all", "includeIf.gitdir:/elsewhere/.path"]);
|
||||
await removeAll();
|
||||
});
|
||||
|
||||
it("takes out a signing setup the user made themselves, keeping their key file", async () => {
|
||||
const mine = path.join(ROOT, "my-own-key");
|
||||
fs.writeFileSync(mine, "not ours");
|
||||
execFileSync("git", ["config", "--global", "user.signingkey", mine]);
|
||||
execFileSync("git", ["config", "--global", "commit.gpgsign", "true"]);
|
||||
const kept = await request("PUT", "/api/me/git/signing", { key: null });
|
||||
assert.equal(kept.body.signing.state, "foreign");
|
||||
const r = await request("PUT", "/api/me/git/signing", { key: null, clearForeign: true });
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.signing.state, "off");
|
||||
assert.equal(gitGet("user.signingkey"), "");
|
||||
assert.equal(gitGet("commit.gpgsign"), "");
|
||||
assert.equal(fs.readFileSync(mine, "utf8"), "not ours");
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@
|
||||
* fine, delete/restore work with no workspace, a copy goes home ↔ workspace
|
||||
* under each side's own rule — and in server mode a developer never reaches
|
||||
* another user's home while an admin gets it read-only — except its Trash,
|
||||
* and a resumable upload in parts lands in the home under the same rule,
|
||||
* which the admin lists, restores from and purges as the member's own.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -166,6 +167,38 @@ describe("File Explorer on the home", () => {
|
||||
assert.ok(!fs.existsSync(path.join(WS, "id_ed25519")));
|
||||
});
|
||||
|
||||
it("resumes an upload into the home in parts, under the home rule", async () => {
|
||||
const raw = (method, p, body) =>
|
||||
fetch(`${base}${p}`, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/octet-stream" },
|
||||
body,
|
||||
}).then(async (r) => ({ status: r.status, body: await r.json() }));
|
||||
const id = "home-resume-00000001";
|
||||
const data = Buffer.from("home bytes");
|
||||
assert.equal(
|
||||
(
|
||||
await raw(
|
||||
"POST",
|
||||
`${H}/upload-part?upload=${id}&offset=0&total=${data.length}`,
|
||||
data.subarray(0, 4)
|
||||
)
|
||||
).body.received,
|
||||
4
|
||||
);
|
||||
assert.equal((await request("GET", `${H}/upload-part?upload=${id}`)).body.received, 4);
|
||||
await raw(
|
||||
"POST",
|
||||
`${H}/upload-part?upload=${id}&offset=4&total=${data.length}`,
|
||||
data.subarray(4)
|
||||
);
|
||||
// The home rule still holds for the staged bytes: no top-level dot entry.
|
||||
assert.equal((await raw("POST", `${H}/upload?path=.ssh/x&staged=${id}`)).status, 403);
|
||||
const ok = await raw("POST", `${H}/upload?path=docs/up.txt&staged=${id}`);
|
||||
assert.equal(ok.status, 201);
|
||||
assert.equal(fs.readFileSync(path.join(HOME, "docs/up.txt"), "utf8"), "home bytes");
|
||||
});
|
||||
|
||||
it("refuses an unknown home", async () => {
|
||||
assert.equal((await request("GET", "/api/homes/someone/fs/list")).status, 404);
|
||||
});
|
||||
@@ -256,13 +289,10 @@ describe("an admin manages a member's Trash", () => {
|
||||
|
||||
// …and every user's in one list, each item tagged with its home and owner.
|
||||
const all = (await call("GET", "/api/homes/trash", dan)).body;
|
||||
assert.deepEqual(
|
||||
all.items.map((i) => [i.name, i.home, i.owner]).sort(),
|
||||
[
|
||||
["junk", "cara", "cara"],
|
||||
["old", "cara", "cara"],
|
||||
]
|
||||
);
|
||||
assert.deepEqual(all.items.map((i) => [i.name, i.home, i.owner]).sort(), [
|
||||
["junk", "cara", "cara"],
|
||||
["old", "cara", "cara"],
|
||||
]);
|
||||
// A developer's merged view is only their own.
|
||||
assert.deepEqual(
|
||||
(await call("GET", "/api/homes/trash", cara)).body.items.map((i) => i.home),
|
||||
|
||||
@@ -208,6 +208,9 @@ describe("lane-sources instructions and launch", () => {
|
||||
// Claude Code hides its task tools from newer models without this, and the
|
||||
// chat Progress panel is drawn from those calls.
|
||||
assert.ok(newSession.includes("CLAUDE_CODE_ENABLE_TODO_TOOLS=1"));
|
||||
// One wheel notch reaches claude as one report; it scrolls this many lines.
|
||||
if (!process.env.CLAUDE_CODE_SCROLL_SPEED)
|
||||
assert.ok(newSession.includes("CLAUDE_CODE_SCROLL_SPEED=5"));
|
||||
assert.deepEqual(fs.readdirSync(dir), ["CLAUDE.md"]);
|
||||
const text = fs.readFileSync(path.join(dir, "CLAUDE.md"), "utf8");
|
||||
assert.match(text, /^- always answer in Vietnamese/);
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
* server/lib/avatars.js): an account sets, reads and removes its own picture;
|
||||
* only PNG/JPEG/GIF/WebP by magic bytes (an SVG is refused), at most 1 MB,
|
||||
* served with `nosniff` and a `sandbox` CSP; `avatarAt` reaches `/api/auth/me`;
|
||||
* another account never sees it through `/api/me`; staff read it at
|
||||
* `/api/users/:id/avatar` (a developer is refused) and a workspace carries
|
||||
* another account never sees it through `/api/me`; every account reads it at
|
||||
* `/api/users/:id/avatar` (a developer too — only the picture, the rest of
|
||||
* `/users` stays staff-only) and a workspace carries
|
||||
* `owner_avatar_at` for the sidebar's owner groups. A picture from a link
|
||||
* (`POST /api/me/avatar/url`, server/lib/avatar-url.js) is stored, never
|
||||
* downloaded, and the avatar routes redirect to it; https only, never a
|
||||
@@ -196,7 +197,7 @@ it("a picture from a link: https only, never a private or local address", async
|
||||
}
|
||||
});
|
||||
|
||||
it("staff read another account's avatar; workspaces carry owner_avatar_at", async () => {
|
||||
it("every account reads another's avatar; workspaces carry owner_avatar_at", async () => {
|
||||
const ava = await login("ava", "ava-pass-1");
|
||||
const ann = await login("ann", "ann-pass-1");
|
||||
const bob = await login("bob", "bob-pass-1");
|
||||
@@ -210,8 +211,12 @@ it("staff read another account's avatar; workspaces carry owner_avatar_at", asyn
|
||||
assert.deepEqual(Buffer.from(await got.arrayBuffer()), PNG);
|
||||
assert.equal(
|
||||
(await fetch(`${BASE}/api/users/${id}/avatar`, { headers: { cookie: bob } })).status,
|
||||
403
|
||||
200
|
||||
);
|
||||
// Only the picture: the rest of /users stays staff-only for a developer.
|
||||
for (const path of [`/api/users/${id}`, `/api/users/${id}/details`, "/api/users"]) {
|
||||
assert.equal((await fetch(BASE + path, { headers: { cookie: bob } })).status, 403, path);
|
||||
}
|
||||
const lanes = (await (await fetch(BASE + "/api/lanes", { headers: { cookie: ann } })).json())
|
||||
.lanes;
|
||||
assert.ok(lanes.find((l) => l.cwd === "/w/ava").owner_avatar_at);
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @file websocket-mine.test.js
|
||||
* @description Server mode broadcasts (server/websocket.js `broadcast`): an
|
||||
* admin/analyst's session, agent and event frames say whether the session is
|
||||
* their OWN (`mine`), a developer's and local mode's never carry it, and
|
||||
* `GET /api/sessions?mine=1` (server/routes/sessions.js) lists only the
|
||||
* caller's own sessions whatever their role — the pet counts only those.
|
||||
* @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(), "ws-mine-test-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
|
||||
const { db } = require("../db");
|
||||
const ws = require("../websocket");
|
||||
|
||||
const prevMode = process.env.AGENTHUB_MODE;
|
||||
before(() => {
|
||||
const lane = db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)");
|
||||
lane.run("/w/boss", 99); // id 1, the admin's
|
||||
lane.run("/w/dev", 2); // id 2, a developer's
|
||||
const s = db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES (?, ?, 'active', '/w', ?)"
|
||||
);
|
||||
s.run("s-boss", "boss", 1);
|
||||
s.run("s-dev", "dev", 2);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
ws._setClientsForTest(null);
|
||||
if (prevMode === undefined) delete process.env.AGENTHUB_MODE;
|
||||
else process.env.AGENTHUB_MODE = prevMode;
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function client(user) {
|
||||
const got = [];
|
||||
return { readyState: 1, user, got, send: (m) => got.push(JSON.parse(m)) };
|
||||
}
|
||||
|
||||
describe("broadcast `mine`", () => {
|
||||
it("marks an admin's frames by whose session it is; a developer gets only theirs, unmarked", () => {
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
const boss = client({ id: 99, username: "boss", role: "admin" });
|
||||
const dev = client({ id: 2, username: "dev", role: "developer" });
|
||||
ws._setClientsForTest(new Set([boss, dev]));
|
||||
ws.broadcast("session_updated", { id: "s-boss", status: "active" });
|
||||
ws.broadcast("new_event", { session_id: "s-dev", event_type: "PostToolUse" });
|
||||
ws.broadcast("lane_update", { lane: { id: 2 } });
|
||||
assert.deepEqual(
|
||||
boss.got.map((m) => [m.type, m.mine]),
|
||||
[
|
||||
["session_updated", true],
|
||||
["new_event", false],
|
||||
["lane_update", undefined],
|
||||
]
|
||||
);
|
||||
assert.deepEqual(
|
||||
dev.got.map((m) => [m.type, m.mine]),
|
||||
[
|
||||
["new_event", undefined],
|
||||
["lane_update", undefined],
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it("adds nothing in local mode", () => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
const me = client(null);
|
||||
ws._setClientsForTest(new Set([me]));
|
||||
ws.broadcast("session_updated", { id: "s-dev" });
|
||||
assert.equal(me.got[0].mine, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/sessions?mine=1", () => {
|
||||
it("lists only the caller's own sessions, an admin's too", async () => {
|
||||
const express = require("express");
|
||||
const app = express();
|
||||
app.use((req, _res, next) => {
|
||||
req.user = { id: 99, username: "boss", role: "admin" };
|
||||
next();
|
||||
});
|
||||
app.use("/api/sessions", require("../routes/sessions"));
|
||||
const srv = app.listen(0);
|
||||
try {
|
||||
const base = `http://127.0.0.1:${srv.address().port}/api/sessions`;
|
||||
const ids = async (qs) =>
|
||||
(await (await fetch(base + qs)).json()).sessions.map((s) => s.id).sort();
|
||||
assert.deepEqual(await ids("?mine=1"), ["s-boss"]);
|
||||
assert.deepEqual(await ids(""), ["s-boss", "s-dev"]);
|
||||
} finally {
|
||||
srv.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1912,6 +1912,47 @@ db.exec(`
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
`);
|
||||
// Chat between users (server mode, server/lib/chat.js): a direct chat (`dm`,
|
||||
// one per pair — `dm_key` is "<low id>:<high id>") or a group. Only a room's
|
||||
// members read it, an admin included. Files live under <data dir>/chat/.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS chat_rooms (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('dm', 'group')),
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
dm_key TEXT UNIQUE,
|
||||
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
last_message_at TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS chat_members (
|
||||
room_id INTEGER NOT NULL REFERENCES chat_rooms(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
joined_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
last_read_id INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (room_id, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_members_user ON chat_members(user_id);
|
||||
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
room_id INTEGER NOT NULL REFERENCES chat_rooms(id) ON DELETE CASCADE,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'text' CHECK (kind IN ('text', 'system')),
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_messages_room ON chat_messages(room_id, id);
|
||||
CREATE TABLE IF NOT EXISTS chat_files (
|
||||
id TEXT PRIMARY KEY,
|
||||
message_id INTEGER NOT NULL REFERENCES chat_messages(id) ON DELETE CASCADE,
|
||||
room_id INTEGER NOT NULL REFERENCES chat_rooms(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
size INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_files_message ON chat_files(message_id);
|
||||
`);
|
||||
|
||||
// An account's own agent instructions, put before every workspace's in each
|
||||
// pane it owns (server/lib/lane-sources.js, issue #67). Additive.
|
||||
try {
|
||||
|
||||
@@ -237,6 +237,7 @@ function createApp() {
|
||||
app.use("/api/projects", projectsRouter);
|
||||
app.use("/api/locks", locksRouter);
|
||||
app.use("/api/pets", petsRouter);
|
||||
app.use("/api/chat", require("./routes/chat"));
|
||||
app.use("/api/snapshots", snapshotsRouter);
|
||||
app.use("/api/artifacts", require("./routes/artifacts"));
|
||||
app.use("/api/alerts", alertsRouter);
|
||||
|
||||
@@ -128,6 +128,11 @@ const STAFF_ONLY_PREFIXES = [
|
||||
// Staff-only paths with an id in the middle, matched on `apiPath()`'s
|
||||
// lower-cased form: the host's own AI-tool config files (they may hold keys).
|
||||
const STAFF_ONLY_PATTERNS = [/^\/ai-tools\/[^/]+\/config(\/|$)/];
|
||||
// The one read under a staff-only prefix every signed-in user may make: an
|
||||
// account's PICTURE (`GET /users/:id/avatar` — the image or a redirect to its
|
||||
// link, nothing else of the account). System Health shows every user's name and
|
||||
// `avatarAt` to everyone, so a developer refused here saw only initials.
|
||||
const EVERYONE_READS = [/^\/users\/\d+\/avatar$/];
|
||||
|
||||
const isServerMode = () => process.env.AGENTHUB_MODE === "server";
|
||||
|
||||
@@ -789,6 +794,7 @@ function authGuard(req, res, next) {
|
||||
const isRead = req.method === "GET" || req.method === "HEAD";
|
||||
if (
|
||||
user.role === "developer" &&
|
||||
!(isRead && EVERYONE_READS.some((re) => re.test(lp))) &&
|
||||
(STAFF_ONLY_PREFIXES.some(under) || STAFF_ONLY_PATTERNS.some((re) => re.test(lp)))
|
||||
) {
|
||||
return deny(res, 403, "EADMIN", "only an admin or analyst may open this");
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* @file chat.js
|
||||
* @description Chat between the users of a server-mode install, shown in the
|
||||
* pet's window beside the chat with the pet: a direct chat (`dm`, at most one
|
||||
* per pair of users) or a group anyone may start and name. Only a room's
|
||||
* MEMBERS read or write it — an admin or analyst included, and there is no
|
||||
* route that lists another user's rooms. A group's creator adds, removes and
|
||||
* renames; any member may leave. A message is text (≤ 8000 chars) and up to
|
||||
* `MAX_FILES` files of ≤ `MAX_FILE_BYTES` each, stored once under
|
||||
* `<data dir>/chat/<room id>/<random id>` — a name the server makes, never one
|
||||
* from the request — and served back only to the room's members (the route
|
||||
* adds a `sandbox` CSP and `nosniff`; only raster images are shown inline).
|
||||
* Every change is told to exactly the users concerned
|
||||
* (`chat_message` / `chat_room` frames, `to` = their ids, see
|
||||
* `scope.exactAudience`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const crypto = require("node:crypto");
|
||||
const { getDataDir } = require("./claude-home");
|
||||
|
||||
const db = () => require("../db").db;
|
||||
const sql = (q) => db().prepare(q);
|
||||
|
||||
const MAX_BODY = 8000;
|
||||
const MAX_NAME = 80;
|
||||
const MAX_FILES = 10;
|
||||
const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
const MAX_GROUP = 200;
|
||||
/** Raster types shown inline; anything else is a download. */
|
||||
const INLINE_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
||||
|
||||
function err(code, message, status = 400) {
|
||||
return Object.assign(new Error(message), { code, status });
|
||||
}
|
||||
|
||||
const chatDir = () => path.join(getDataDir(), "chat");
|
||||
const fileDir = (roomId) => path.join(chatDir(), String(Number(roomId)));
|
||||
|
||||
/** A user who may be chatted with: exists and is not disabled. */
|
||||
function activeUser(id) {
|
||||
const n = Number(id);
|
||||
if (!Number.isSafeInteger(n) || n <= 0) return null;
|
||||
return sql("SELECT id FROM users WHERE id = ? AND COALESCE(disabled, 0) = 0").get(n) || null;
|
||||
}
|
||||
|
||||
/** Every enabled account but the caller — who a chat can be started with. */
|
||||
function people(me) {
|
||||
return sql(
|
||||
`SELECT id, username, display_name, avatar_at FROM users
|
||||
WHERE COALESCE(disabled, 0) = 0 AND id != ? ORDER BY COALESCE(display_name, username)`
|
||||
)
|
||||
.all(me)
|
||||
.map((u) => ({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
displayName: u.display_name ?? null,
|
||||
avatarAt: u.avatar_at ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
function memberIds(roomId) {
|
||||
return sql("SELECT user_id FROM chat_members WHERE room_id = ? ORDER BY user_id")
|
||||
.all(roomId)
|
||||
.map((r) => r.user_id);
|
||||
}
|
||||
|
||||
/** The room, when `me` is one of its members; else ENOTFOUND (404) — never "forbidden". */
|
||||
function roomFor(me, roomId) {
|
||||
const id = Number(roomId);
|
||||
const row =
|
||||
Number.isSafeInteger(id) && id > 0
|
||||
? sql(
|
||||
`SELECT r.* FROM chat_rooms r JOIN chat_members m ON m.room_id = r.id
|
||||
WHERE r.id = ? AND m.user_id = ?`
|
||||
).get(id, me)
|
||||
: null;
|
||||
if (!row) throw err("ENOTFOUND", "no such chat", 404);
|
||||
return row;
|
||||
}
|
||||
|
||||
function filesOf(messageIds) {
|
||||
if (!messageIds.length) return new Map();
|
||||
const rows = sql(
|
||||
`SELECT id, message_id, name, type, size FROM chat_files
|
||||
WHERE message_id IN (${messageIds.map(() => "?").join(",")}) ORDER BY rowid`
|
||||
).all(...messageIds);
|
||||
const out = new Map();
|
||||
for (const f of rows) {
|
||||
if (!out.has(f.message_id)) out.set(f.message_id, []);
|
||||
out.get(f.message_id).push({ id: f.id, name: f.name, type: f.type, size: f.size });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function shapeMessages(rows) {
|
||||
const files = filesOf(rows.map((r) => r.id));
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
roomId: r.room_id,
|
||||
userId: r.user_id,
|
||||
kind: r.kind,
|
||||
body: r.body,
|
||||
createdAt: r.created_at,
|
||||
files: files.get(r.id) || [],
|
||||
}));
|
||||
}
|
||||
|
||||
/** One room as `me` sees it: members, newest message, unread count. */
|
||||
function shapeRoom(me, r) {
|
||||
const mine = sql("SELECT last_read_id FROM chat_members WHERE room_id = ? AND user_id = ?").get(
|
||||
r.id,
|
||||
me
|
||||
);
|
||||
const last = sql("SELECT * FROM chat_messages WHERE room_id = ? ORDER BY id DESC LIMIT 1").get(
|
||||
r.id
|
||||
);
|
||||
const unread = sql(
|
||||
"SELECT COUNT(*) AS n FROM chat_messages WHERE room_id = ? AND id > ? AND COALESCE(user_id, -1) != ?"
|
||||
).get(r.id, mine ? mine.last_read_id : 0, me).n;
|
||||
return {
|
||||
id: r.id,
|
||||
kind: r.kind,
|
||||
name: r.name,
|
||||
createdBy: r.created_by,
|
||||
createdAt: r.created_at,
|
||||
lastMessageAt: r.last_message_at,
|
||||
members: memberIds(r.id),
|
||||
lastMessage: last ? shapeMessages([last])[0] : null,
|
||||
lastReadId: mine ? mine.last_read_id : 0,
|
||||
unread,
|
||||
};
|
||||
}
|
||||
|
||||
/** `me`'s rooms, the most recently active first. */
|
||||
function listRooms(me) {
|
||||
return sql(
|
||||
`SELECT r.* FROM chat_rooms r JOIN chat_members m ON m.room_id = r.id
|
||||
WHERE m.user_id = ? ORDER BY COALESCE(r.last_message_at, r.created_at) DESC`
|
||||
)
|
||||
.all(me)
|
||||
.map((r) => shapeRoom(me, r));
|
||||
}
|
||||
|
||||
function cleanName(name) {
|
||||
if (name === undefined || name === null) return "";
|
||||
if (typeof name !== "string") throw err("EBADFIELD", "name: text");
|
||||
return name.trim().slice(0, MAX_NAME);
|
||||
}
|
||||
|
||||
function userList(value, field) {
|
||||
if (value === undefined) return [];
|
||||
if (!Array.isArray(value) || value.length > MAX_GROUP)
|
||||
throw err("EBADFIELD", `${field}: a list of user ids`);
|
||||
const ids = [...new Set(value.map(Number))];
|
||||
for (const id of ids) if (!activeUser(id)) throw err("ENOUSER", `no such user: ${id}`);
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** The direct chat between `me` and `other`, made on first use. `created` says which. */
|
||||
function openDm(me, other) {
|
||||
const them = activeUser(other);
|
||||
if (!them || them.id === me) throw err("ENOUSER", "pick another user");
|
||||
const key = [me, them.id].sort((a, b) => a - b).join(":");
|
||||
const found = sql("SELECT * FROM chat_rooms WHERE dm_key = ?").get(key);
|
||||
if (found) return { room: shapeRoom(me, found), created: false };
|
||||
const id = db().transaction(() => {
|
||||
const { lastInsertRowid } = sql(
|
||||
"INSERT INTO chat_rooms (kind, dm_key, created_by) VALUES ('dm', ?, ?)"
|
||||
).run(key, me);
|
||||
const add = sql("INSERT INTO chat_members (room_id, user_id) VALUES (?, ?)");
|
||||
add.run(lastInsertRowid, me);
|
||||
add.run(lastInsertRowid, them.id);
|
||||
return lastInsertRowid;
|
||||
})();
|
||||
return {
|
||||
room: shapeRoom(me, sql("SELECT * FROM chat_rooms WHERE id = ?").get(id)),
|
||||
created: true,
|
||||
};
|
||||
}
|
||||
|
||||
function systemLine(roomId, me, body) {
|
||||
sql("INSERT INTO chat_messages (room_id, user_id, kind, body) VALUES (?, ?, 'system', ?)").run(
|
||||
roomId,
|
||||
me,
|
||||
body
|
||||
);
|
||||
sql(
|
||||
"UPDATE chat_rooms SET last_message_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?"
|
||||
).run(roomId);
|
||||
}
|
||||
|
||||
/** A new group of `me` plus `userIds`. */
|
||||
function createGroup(me, { name, userIds }) {
|
||||
const title = cleanName(name);
|
||||
const ids = userList(userIds, "userIds").filter((id) => id !== me);
|
||||
if (!ids.length) throw err("EBADFIELD", "a group needs at least one other member");
|
||||
const id = db().transaction(() => {
|
||||
const { lastInsertRowid } = sql(
|
||||
"INSERT INTO chat_rooms (kind, name, created_by) VALUES ('group', ?, ?)"
|
||||
).run(title, me);
|
||||
const add = sql("INSERT INTO chat_members (room_id, user_id) VALUES (?, ?)");
|
||||
for (const uid of [me, ...ids]) add.run(lastInsertRowid, uid);
|
||||
systemLine(lastInsertRowid, me, "created");
|
||||
return lastInsertRowid;
|
||||
})();
|
||||
return shapeRoom(me, sql("SELECT * FROM chat_rooms WHERE id = ?").get(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* A group's creator renames it, adds or removes members. Answers the room and
|
||||
* `notify` = every user to tell (removed ones included, so they drop it).
|
||||
*/
|
||||
function updateGroup(me, roomId, { name, add, remove }) {
|
||||
const room = roomFor(me, roomId);
|
||||
if (room.kind !== "group") throw err("EBADFIELD", "a direct chat has no members to change");
|
||||
if (room.created_by !== me) throw err("EFORBIDDEN", "only the group's creator changes it", 403);
|
||||
const before = memberIds(room.id);
|
||||
const adds = userList(add, "add").filter((id) => !before.includes(id));
|
||||
const drops = userList(remove, "remove").filter((id) => id !== me && before.includes(id));
|
||||
db().transaction(() => {
|
||||
if (name !== undefined) {
|
||||
const title = cleanName(name);
|
||||
if (title !== room.name) {
|
||||
sql("UPDATE chat_rooms SET name = ? WHERE id = ?").run(title, room.id);
|
||||
systemLine(room.id, me, "renamed");
|
||||
}
|
||||
}
|
||||
const ins = sql("INSERT OR IGNORE INTO chat_members (room_id, user_id) VALUES (?, ?)");
|
||||
for (const uid of adds) ins.run(room.id, uid);
|
||||
const del = sql("DELETE FROM chat_members WHERE room_id = ? AND user_id = ?");
|
||||
for (const uid of drops) del.run(room.id, uid);
|
||||
if (adds.length) systemLine(room.id, me, `added:${adds.join(",")}`);
|
||||
if (drops.length) systemLine(room.id, me, `removed:${drops.join(",")}`);
|
||||
})();
|
||||
return {
|
||||
room: shapeRoom(me, sql("SELECT * FROM chat_rooms WHERE id = ?").get(room.id)),
|
||||
notify: [...new Set([...before, ...adds])],
|
||||
};
|
||||
}
|
||||
|
||||
/** `me` leaves a group; the last one out deletes it and its files. Answers who to tell. */
|
||||
function leaveGroup(me, roomId) {
|
||||
const room = roomFor(me, roomId);
|
||||
if (room.kind !== "group") throw err("EBADFIELD", "a direct chat cannot be left");
|
||||
const before = memberIds(room.id);
|
||||
db().transaction(() => {
|
||||
sql("DELETE FROM chat_members WHERE room_id = ? AND user_id = ?").run(room.id, me);
|
||||
if (before.length > 1) systemLine(room.id, me, "left");
|
||||
else sql("DELETE FROM chat_rooms WHERE id = ?").run(room.id);
|
||||
})();
|
||||
if (before.length <= 1) fs.rmSync(fileDir(room.id), { recursive: true, force: true });
|
||||
return { notify: before };
|
||||
}
|
||||
|
||||
/** A page of messages, oldest first: the newest `limit`, or those before `before`. */
|
||||
function messages(me, roomId, { before, limit } = {}) {
|
||||
const room = roomFor(me, roomId);
|
||||
const n = Math.min(Math.max(parseInt(limit, 10) || 50, 1), 200);
|
||||
const b = parseInt(before, 10);
|
||||
const rows = Number.isSafeInteger(b)
|
||||
? sql("SELECT * FROM chat_messages WHERE room_id = ? AND id < ? ORDER BY id DESC LIMIT ?").all(
|
||||
room.id,
|
||||
b,
|
||||
n
|
||||
)
|
||||
: sql("SELECT * FROM chat_messages WHERE room_id = ? ORDER BY id DESC LIMIT ?").all(room.id, n);
|
||||
return { messages: shapeMessages(rows.reverse()), hasMore: rows.length === n };
|
||||
}
|
||||
|
||||
/** A file's media type, kept only when it is a plain `type/subtype`. */
|
||||
function cleanType(t) {
|
||||
return typeof t === "string" && /^[a-z0-9.+-]+\/[a-z0-9.+-]+$/i.test(t)
|
||||
? t.toLowerCase()
|
||||
: "application/octet-stream";
|
||||
}
|
||||
|
||||
function cleanFileName(n) {
|
||||
const base = path.basename(String(n || "file")).replace(/[\u0000-\u001f\u007f]/g, "");
|
||||
return base.slice(0, 200) || "file";
|
||||
}
|
||||
|
||||
/**
|
||||
* `me` posts to a room: `body` text and `files` ([{name, type, buffer}]).
|
||||
* Answers the message and who to tell (every member).
|
||||
*/
|
||||
function post(me, roomId, { body, files = [] }) {
|
||||
const room = roomFor(me, roomId);
|
||||
const text = typeof body === "string" ? body.replace(/\s+$/, "") : "";
|
||||
if (text.length > MAX_BODY)
|
||||
throw err("ETOOBIG", `a message is at most ${MAX_BODY} characters`, 413);
|
||||
if (files.length > MAX_FILES) throw err("ETOOBIG", `at most ${MAX_FILES} files`, 413);
|
||||
for (const f of files)
|
||||
if (f.buffer.length > MAX_FILE_BYTES) throw err("ETOOBIG", "a file is at most 25 MB", 413);
|
||||
if (!text.trim() && !files.length) throw err("EBADFIELD", "an empty message");
|
||||
const dir = fileDir(room.id);
|
||||
const stored = files.map((f) => ({
|
||||
id: crypto.randomBytes(16).toString("hex"),
|
||||
name: cleanFileName(f.name),
|
||||
type: cleanType(f.type),
|
||||
buffer: f.buffer,
|
||||
}));
|
||||
if (stored.length) {
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
for (const f of stored) fs.writeFileSync(path.join(dir, f.id), f.buffer, { mode: 0o600 });
|
||||
}
|
||||
let id;
|
||||
try {
|
||||
id = db().transaction(() => {
|
||||
const { lastInsertRowid } = sql(
|
||||
"INSERT INTO chat_messages (room_id, user_id, body) VALUES (?, ?, ?)"
|
||||
).run(room.id, me, text);
|
||||
const ins = sql(
|
||||
"INSERT INTO chat_files (id, message_id, room_id, name, type, size) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
for (const f of stored)
|
||||
ins.run(f.id, lastInsertRowid, room.id, f.name, f.type, f.buffer.length);
|
||||
sql(
|
||||
"UPDATE chat_rooms SET last_message_at = (SELECT created_at FROM chat_messages WHERE id = ?) WHERE id = ?"
|
||||
).run(lastInsertRowid, room.id);
|
||||
// Your own message is read.
|
||||
sql("UPDATE chat_members SET last_read_id = ? WHERE room_id = ? AND user_id = ?").run(
|
||||
lastInsertRowid,
|
||||
room.id,
|
||||
me
|
||||
);
|
||||
return lastInsertRowid;
|
||||
})();
|
||||
} catch (e) {
|
||||
for (const f of stored) fs.rmSync(path.join(dir, f.id), { force: true });
|
||||
throw e;
|
||||
}
|
||||
const [message] = shapeMessages([sql("SELECT * FROM chat_messages WHERE id = ?").get(id)]);
|
||||
return { message, notify: memberIds(room.id) };
|
||||
}
|
||||
|
||||
/** `me` has read the room up to message `upTo` (never moves back). */
|
||||
function markRead(me, roomId, upTo) {
|
||||
const room = roomFor(me, roomId);
|
||||
const n = Number(upTo);
|
||||
if (!Number.isSafeInteger(n) || n < 0) throw err("EBADFIELD", "upTo: a message id");
|
||||
sql(
|
||||
"UPDATE chat_members SET last_read_id = MAX(last_read_id, ?) WHERE room_id = ? AND user_id = ?"
|
||||
).run(n, room.id, me);
|
||||
return { lastReadId: Math.max(n, 0) };
|
||||
}
|
||||
|
||||
/** A file of a room `me` is in: `{path, name, type, inline}`. */
|
||||
function file(me, roomId, fileId) {
|
||||
const room = roomFor(me, roomId);
|
||||
if (typeof fileId !== "string" || !/^[0-9a-f]{32}$/.test(fileId))
|
||||
throw err("ENOTFOUND", "no such file", 404);
|
||||
const f = sql("SELECT * FROM chat_files WHERE id = ? AND room_id = ?").get(fileId, room.id);
|
||||
const p = f && path.join(fileDir(room.id), f.id);
|
||||
if (!f || !fs.existsSync(p)) throw err("ENOTFOUND", "no such file", 404);
|
||||
return { path: p, name: f.name, type: f.type, inline: INLINE_TYPES.has(f.type) };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_FILES,
|
||||
MAX_FILE_BYTES,
|
||||
people,
|
||||
listRooms,
|
||||
openDm,
|
||||
createGroup,
|
||||
updateGroup,
|
||||
leaveGroup,
|
||||
messages,
|
||||
post,
|
||||
markRead,
|
||||
file,
|
||||
roomFor,
|
||||
shapeRoom,
|
||||
};
|
||||
@@ -634,16 +634,26 @@ async function purgeTrash(dir, userKey, id) {
|
||||
|
||||
// ── Upload ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** A pass-through that fails once more than `max` bytes went by. */
|
||||
/** A pass-through that fails once more than `max` bytes went by (`seen()`: the count). */
|
||||
function capped(max) {
|
||||
let n = 0;
|
||||
return new Transform({
|
||||
const t = new Transform({
|
||||
transform(chunk, _enc, cb) {
|
||||
n += chunk.length;
|
||||
if (n > max) return cb(bad("ETOOBIG", `a file may be at most ${max / 1024 / 1024} MB`));
|
||||
cb(null, chunk);
|
||||
},
|
||||
});
|
||||
t.seen = () => n;
|
||||
return t;
|
||||
}
|
||||
|
||||
/** A body that stopped short of its announced length (a dropped connection). */
|
||||
function shortBody(limit, length) {
|
||||
if (length == null || length === "") return null;
|
||||
const want = Number(length);
|
||||
if (!Number.isSafeInteger(want) || limit.seen() === want) return null;
|
||||
return bad("EBADBODY", `the upload ended early (${limit.seen()} of ${want} bytes)`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -677,16 +687,41 @@ async function upload(dir, rel, stream, { conflict, length, trash } = {}) {
|
||||
}
|
||||
const acct = accountAt(loc.abs);
|
||||
const limit = capped(UPLOAD_MAX_BYTES);
|
||||
if (acct) {
|
||||
// write-as `replace`: a new file renamed over the name, as the member.
|
||||
const done = runAs.writeAsAsync(acct, loc.abs, ["replace"], { input: limit });
|
||||
await Promise.all([
|
||||
const feed = (done) =>
|
||||
Promise.all([
|
||||
pipeline(stream, limit).catch((e) => {
|
||||
limit.destroy(e);
|
||||
throw e;
|
||||
}),
|
||||
done,
|
||||
]);
|
||||
if (acct && runAs.writeAsCanMove()) {
|
||||
// As the member: a hidden temp file, moved onto the name only once every
|
||||
// byte arrived. write-as cannot tell a dropped connection (or a server
|
||||
// that died mid-upload) from the end of the file — writing the name
|
||||
// itself would leave a truncated file there under its real name.
|
||||
const tmp = path.join(
|
||||
loc.parent.abs,
|
||||
`.${loc.name}.agenthub-upload-${crypto.randomBytes(4).toString("hex")}`
|
||||
);
|
||||
try {
|
||||
await feed(runAs.writeAsAsync(acct, tmp, [], { input: limit }));
|
||||
const short = shortBody(limit, length);
|
||||
if (short) throw short;
|
||||
// `move` never lands on an existing name: one taken meanwhile is refused.
|
||||
await runAs.writeAsAsync(acct, tmp, ["move", loc.abs]).catch((e) => {
|
||||
if (/exists/.test(e.message)) {
|
||||
throw bad("EEXISTS", `${loc.rel} already exists`, { names: [loc.name] });
|
||||
}
|
||||
throw e;
|
||||
});
|
||||
} catch (e) {
|
||||
await runAs.writeAsAsync(acct, tmp, ["remove"]).catch(() => {});
|
||||
throw e;
|
||||
}
|
||||
} else if (acct) {
|
||||
// An older write-as without `move`: `replace` (a new file renamed over the name).
|
||||
await feed(runAs.writeAsAsync(acct, loc.abs, ["replace"], { input: limit }));
|
||||
} else {
|
||||
const tmp = path.join(
|
||||
loc.parent.abs,
|
||||
@@ -694,6 +729,8 @@ async function upload(dir, rel, stream, { conflict, length, trash } = {}) {
|
||||
);
|
||||
try {
|
||||
await pipeline(stream, limit, fs.createWriteStream(tmp, { flags: "wx" }));
|
||||
const short = shortBody(limit, length);
|
||||
if (short) throw short;
|
||||
// Linked, not renamed: a name taken meanwhile is refused, never replaced.
|
||||
await fs.promises.link(tmp, loc.abs).catch(async (e) => {
|
||||
if (e.code === "EEXIST")
|
||||
|
||||
@@ -190,6 +190,11 @@ function makeKeyPair(comment) {
|
||||
const pair = crypto.generateKeyPairSync("ed25519");
|
||||
const pub = Buffer.from(pair.publicKey.export({ format: "jwk" }).x, "base64url");
|
||||
const seed = Buffer.from(pair.privateKey.export({ format: "jwk" }).d, "base64url");
|
||||
return encodeEd25519(seed, pub, comment);
|
||||
}
|
||||
|
||||
/** An ed25519 key (32-byte seed + public half) in OpenSSH's own formats. */
|
||||
function encodeEd25519(seed, pub, comment) {
|
||||
const type = Buffer.from("ssh-ed25519");
|
||||
const pubBlob = Buffer.concat([sshString(type), sshString(pub)]);
|
||||
const check = crypto.randomBytes(4);
|
||||
@@ -220,6 +225,182 @@ function makeKeyPair(comment) {
|
||||
};
|
||||
}
|
||||
|
||||
/** `ssh-rsa …` for an RSA key object (private or public). */
|
||||
function rsaPublicLine(key, comment) {
|
||||
const jwk = crypto.createPublicKey(key).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 `ssh-rsa ${blob.toString("base64")} ${comment}\n`;
|
||||
}
|
||||
|
||||
// ── An imported key ────────────────────────────────────────────────────────
|
||||
|
||||
/** A pasted key file is a few KB; anything far past that is not one. */
|
||||
const IMPORT_MAX = 32 * 1024;
|
||||
const PASSPHRASE_HINT =
|
||||
"this key has a passphrase — a commit made from the dashboard has nowhere to type it. " +
|
||||
'Make a copy without one on your own machine (ssh-keygen -p -N "" -f <copy>) and import the copy';
|
||||
|
||||
/** Reads SSH wire strings out of a buffer; EBADKEY on a short one. */
|
||||
function wireReader(buf) {
|
||||
let at = 0;
|
||||
const need = (n) => {
|
||||
if (at + n > buf.length) throw err("EBADKEY", "not a valid SSH private key");
|
||||
};
|
||||
return {
|
||||
u32() {
|
||||
need(4);
|
||||
const v = buf.readUInt32BE(at);
|
||||
at += 4;
|
||||
return v;
|
||||
},
|
||||
bytes() {
|
||||
const n = this.u32();
|
||||
need(n);
|
||||
const v = buf.subarray(at, at + n);
|
||||
at += n;
|
||||
return v;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const b64u = (b) => Buffer.from(b).toString("base64url");
|
||||
const big = (b) => BigInt(`0x${Buffer.from(b).toString("hex") || "0"}`);
|
||||
const unbig = (n) => {
|
||||
let h = n.toString(16);
|
||||
if (h.length % 2) h = `0${h}`;
|
||||
return Buffer.from(h, "hex");
|
||||
};
|
||||
/** An SSH mpint's magnitude without its sign byte. */
|
||||
const mag = (b) => (b[0] === 0 && b.length > 1 ? b.subarray(1) : b);
|
||||
|
||||
/**
|
||||
* An `openssh-key-v1` file (`BEGIN OPENSSH PRIVATE KEY`) as a KeyObject plus
|
||||
* its comment. Encrypted ones are refused (EKEYPASSPHRASE): reading them
|
||||
* needs the passphrase, and signing from the dashboard would need it again.
|
||||
*/
|
||||
function parseOpenSsh(text) {
|
||||
const b64 = text.replace(/-----(BEGIN|END) OPENSSH PRIVATE KEY-----/g, "").replace(/\s+/g, "");
|
||||
const buf = Buffer.from(b64, "base64");
|
||||
const magic = Buffer.from("openssh-key-v1\0");
|
||||
if (!buf.subarray(0, magic.length).equals(magic)) {
|
||||
throw err("EBADKEY", "not a valid SSH private key");
|
||||
}
|
||||
const r = wireReader(buf.subarray(magic.length));
|
||||
const cipher = r.bytes().toString();
|
||||
r.bytes(); // kdf name
|
||||
r.bytes(); // kdf options
|
||||
if (cipher !== "none") throw err("EKEYPASSPHRASE", PASSPHRASE_HINT);
|
||||
if (r.u32() !== 1) throw err("EBADKEY", "a key file holding one key");
|
||||
r.bytes(); // the public blob — checked against the private half below
|
||||
const p = wireReader(r.bytes());
|
||||
if (p.u32() !== p.u32()) throw err("EBADKEY", "not a valid SSH private key");
|
||||
const type = p.bytes().toString();
|
||||
if (type === "ssh-ed25519") {
|
||||
const pub = p.bytes();
|
||||
const sk = p.bytes();
|
||||
if (pub.length !== 32 || sk.length !== 64 || !sk.subarray(32).equals(pub)) {
|
||||
throw err("EBADKEY", "not a valid SSH private key");
|
||||
}
|
||||
const key = crypto.createPrivateKey({
|
||||
key: { kty: "OKP", crv: "Ed25519", d: b64u(sk.subarray(0, 32)), x: b64u(pub) },
|
||||
format: "jwk",
|
||||
});
|
||||
return { key, comment: p.bytes().toString("utf8") };
|
||||
}
|
||||
if (type === "ssh-rsa") {
|
||||
const n = mag(p.bytes());
|
||||
const e = mag(p.bytes());
|
||||
const d = mag(p.bytes());
|
||||
const qi = mag(p.bytes());
|
||||
const pp = mag(p.bytes());
|
||||
const q = mag(p.bytes());
|
||||
const D = big(d);
|
||||
const key = crypto.createPrivateKey({
|
||||
key: {
|
||||
kty: "RSA",
|
||||
n: b64u(n),
|
||||
e: b64u(e),
|
||||
d: b64u(d),
|
||||
p: b64u(pp),
|
||||
q: b64u(q),
|
||||
dp: b64u(unbig(D % (big(pp) - 1n))),
|
||||
dq: b64u(unbig(D % (big(q) - 1n))),
|
||||
qi: b64u(qi),
|
||||
},
|
||||
format: "jwk",
|
||||
});
|
||||
return { key, comment: p.bytes().toString("utf8") };
|
||||
}
|
||||
throw err("EBADKEYTYPE", `an ${type.replace(/^ssh-/, "")} key — import an ed25519 or RSA one`);
|
||||
}
|
||||
|
||||
/**
|
||||
* A pasted or uploaded private key → `{type, privateKey, publicKey}` in the
|
||||
* same formats a generated key is written in. Takes OpenSSH's own format and
|
||||
* the PEM ones (PKCS#1, PKCS#8); refuses an encrypted key (EKEYPASSPHRASE),
|
||||
* anything but ed25519 or RSA ≥ 2048 bits (EBADKEYTYPE), and a key whose
|
||||
* halves do not sign and verify as one (EBADKEY).
|
||||
*/
|
||||
function parsePrivateKey(text, fallbackComment) {
|
||||
const src = typeof text === "string" ? text.replace(/\r\n?/g, "\n").trim() : "";
|
||||
if (!src || src.length > IMPORT_MAX || !/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/.test(src)) {
|
||||
throw err(
|
||||
"EBADKEY",
|
||||
/^ssh-|^ecdsa-/.test(src)
|
||||
? "that is a PUBLIC key (.pub) — import the private key file next to it"
|
||||
: "not an SSH private key — the file starts with -----BEGIN … PRIVATE KEY-----"
|
||||
);
|
||||
}
|
||||
let key;
|
||||
let comment = "";
|
||||
if (/-----BEGIN OPENSSH PRIVATE KEY-----/.test(src)) {
|
||||
({ key, comment } = parseOpenSsh(src));
|
||||
} else {
|
||||
if (/ENCRYPTED/.test(src)) throw err("EKEYPASSPHRASE", PASSPHRASE_HINT);
|
||||
try {
|
||||
key = crypto.createPrivateKey(src);
|
||||
} catch (e) {
|
||||
if (/passphrase/i.test(String(e?.code || e?.message))) {
|
||||
throw err("EKEYPASSPHRASE", PASSPHRASE_HINT);
|
||||
}
|
||||
throw err("EBADKEY", "not a valid SSH private key");
|
||||
}
|
||||
}
|
||||
const kind = key.asymmetricKeyType;
|
||||
if (kind !== "ed25519" && kind !== "rsa") {
|
||||
throw err("EBADKEYTYPE", `an ${kind || "unknown"} key — import an ed25519 or RSA one`);
|
||||
}
|
||||
if (kind === "rsa" && (key.asymmetricKeyDetails?.modulusLength ?? 0) < 2048) {
|
||||
throw err("EBADKEYTYPE", "an RSA key under 2048 bits — too weak to sign with");
|
||||
}
|
||||
// The halves belong together: a mangled paste fails here, not at commit time.
|
||||
const probe = Buffer.from("agenthub key check");
|
||||
const hash = kind === "rsa" ? "sha256" : null;
|
||||
if (!crypto.verify(hash, probe, crypto.createPublicKey(key), crypto.sign(hash, probe, key))) {
|
||||
throw err("EBADKEY", "the key's private and public halves do not match");
|
||||
}
|
||||
const label =
|
||||
String(comment || fallbackComment || "agenthub")
|
||||
.replace(/[^\w.@+-]/g, "")
|
||||
.slice(0, 64) || "agenthub";
|
||||
if (kind === "ed25519") {
|
||||
const jwk = key.export({ format: "jwk" });
|
||||
return {
|
||||
type: "ed25519",
|
||||
...encodeEd25519(Buffer.from(jwk.d, "base64url"), Buffer.from(jwk.x, "base64url"), label),
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "rsa",
|
||||
privateKey: key.export({ type: "pkcs1", format: "pem" }),
|
||||
publicKey: rsaPublicLine(key, label),
|
||||
};
|
||||
}
|
||||
|
||||
/** `SHA256:…` as `ssh-keygen -l` prints it, from a public key line. */
|
||||
function fingerprint(publicKey) {
|
||||
const blob = Buffer.from(String(publicKey).trim().split(/\s+/)[1] || "", "base64");
|
||||
@@ -321,6 +502,243 @@ async function generateSshKey(
|
||||
return sshKeysInfo(account).find((k) => k.id === id) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a key the user already has (`privateKey`: the file's text) as a
|
||||
* NAMED key beside the generated ones — `name`, else `signing`, `signing-2`…
|
||||
* Written exactly like a generated key (0600, as the member), so it serves
|
||||
* clones and pushes too; only its public half is ever returned. The same key
|
||||
* twice is EEXISTS, naming the key that holds it.
|
||||
*/
|
||||
async function importSshKey(account, { privateKey, name = null, comment } = {}) {
|
||||
const parsed = parsePrivateKey(privateKey, comment);
|
||||
const keys = currentKeys(account);
|
||||
const fp = fingerprint(parsed.publicKey);
|
||||
const twin = keys.find((k) => fingerprint(k.publicKey) === fp);
|
||||
if (twin) throw err("EEXISTS", `this key is already here as ${twin.name ?? twin.type}`);
|
||||
let keyName = name == null || name === "" ? null : String(name).trim().toLowerCase();
|
||||
if (keyName === null) {
|
||||
keyName = "signing";
|
||||
for (let n = 2; keys.some((k) => k.name === keyName); n++) keyName = `signing-${n}`;
|
||||
}
|
||||
if (!KEY_NAME_RE.test(keyName)) {
|
||||
throw err("EBADKEYNAME", "a key name of lowercase letters, digits and -, up to 32");
|
||||
}
|
||||
if (keys.some((k) => k.name === keyName))
|
||||
throw err("EEXISTS", `a key named ${keyName} already exists`);
|
||||
if (keys.length >= MAX_KEYS) throw err("ETOOMANYKEYS", `at most ${MAX_KEYS} SSH keys`);
|
||||
const id = keyIdFor(parsed.type, keyName);
|
||||
const file = path.join(paths(account).sshDir, id);
|
||||
writeText(account, file, parsed.privateKey, { secret: true });
|
||||
writeText(account, `${file}.pub`, parsed.publicKey, { secret: false });
|
||||
if (account.kind === "member" || (await terminalSshState(account)) === "on") {
|
||||
await setTerminalSsh(account, true).catch(() => {});
|
||||
}
|
||||
return sshKeysInfo(account).find((k) => k.id === id) ?? null;
|
||||
}
|
||||
|
||||
// ── Commit signing (`~/.gitconfig` gpg.format / user.signingkey) ───────────
|
||||
|
||||
/** One global git config value, or "" when unset. */
|
||||
async function globalGet(account, key) {
|
||||
const { git } = require("./worktree");
|
||||
return git(homeOf(account), ["config", "--global", "--get", key]).then(
|
||||
(r) => r.stdout.trim(),
|
||||
() => ""
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the account's commits are signed with one of ITS AgentHub keys:
|
||||
* `{state: "on", key}` (gpg.format ssh, user.signingkey = that key's private
|
||||
* file — ssh-keygen signs with the private file, a `.pub` needs an agent the
|
||||
* dashboard has not got — and commit.gpgsign true), `{state: "off"}`, or
|
||||
* `{state: "foreign", path}` when the user set a signing key of their own
|
||||
* (never touched without `replace`).
|
||||
*/
|
||||
async function signingState(account) {
|
||||
const signingKey = await globalGet(account, "user.signingkey");
|
||||
if (!signingKey) return { state: "off", key: null, path: null };
|
||||
const k = currentKeys(account).find((x) => x.file === signingKey);
|
||||
// Not one of our keys — the user's own, or one of ours deleted by hand
|
||||
// (commits then fail): either way show the path, so a key can be picked.
|
||||
if (!k) return { state: "foreign", key: null, path: signingKey };
|
||||
const on =
|
||||
(await globalGet(account, "gpg.format")) === "ssh" &&
|
||||
(await globalGet(account, "commit.gpgsign")) === "true";
|
||||
return { state: on ? "on" : "off", key: on ? k.id : null, path: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign every commit of the account with key `id` (null = stop signing).
|
||||
* Over a signing key the user set themselves only with `replace`
|
||||
* (EFOREIGNSIGN); off removes only our own settings. No value is a secret —
|
||||
* the key's PATH crosses sudo's argv, never its content.
|
||||
*/
|
||||
async function setSigning(account, { id = null, replace = false, clearForeign = false } = {}) {
|
||||
const { git } = require("./worktree");
|
||||
const home = homeOf(account);
|
||||
const state = await signingState(account);
|
||||
const unset = (key) =>
|
||||
git(home, ["config", "--global", "--unset", key]).catch(() => {
|
||||
/* already unset */
|
||||
});
|
||||
if (id == null) {
|
||||
// The user's own setup is left alone unless they asked to take it out
|
||||
// (`clearForeign`): only the three settings go, never their key file.
|
||||
if (state.state === "foreign" && !clearForeign) return state;
|
||||
if ((await globalGet(account, "user.signingkey")) !== "") {
|
||||
await unset("commit.gpgsign");
|
||||
await unset("user.signingkey");
|
||||
await unset("gpg.format");
|
||||
}
|
||||
return signingState(account);
|
||||
}
|
||||
const k = currentKeys(account).find((x) => x.id === id);
|
||||
if (!k) throw err("ENOKEY", "no such SSH key");
|
||||
if (state.state === "foreign" && !replace) {
|
||||
throw err(
|
||||
"EFOREIGNSIGN",
|
||||
`your ~/.gitconfig already signs with ${state.path} — confirm to switch to ${k.name ?? k.type}`
|
||||
);
|
||||
}
|
||||
await git(home, ["config", "--global", "gpg.format", "ssh"]);
|
||||
await git(home, ["config", "--global", "user.signingkey", k.file]);
|
||||
await git(home, ["config", "--global", "commit.gpgsign", "true"]);
|
||||
return signingState(account);
|
||||
}
|
||||
|
||||
// ── Commit signing per workspace (`includeIf "gitdir:…"`) ──────────────────
|
||||
//
|
||||
// A workspace is signed by a small config file of OURS per workspace
|
||||
// (`lane-<id>.gitconfig`: gpg.format ssh, user.signingkey, commit.gpgsign),
|
||||
// pulled into the account's `~/.gitconfig` by `includeIf "gitdir:<dir>"` for
|
||||
// the workspace's own git dir and for the git dirs of the worktrees its
|
||||
// sessions make (`ah-<id>-*`, pty-run.js). Every git that reads the account's
|
||||
// config — the terminal, the Changes panel, an agent — then signs there and
|
||||
// nowhere else; nothing is written into the user's repository.
|
||||
|
||||
const LANE_SIGN_RE = /^lane-(\d+)\.gitconfig$/;
|
||||
|
||||
/** Where the per-workspace files live: ours, beside the account's keys. */
|
||||
function signingDir(account) {
|
||||
return account.kind === "member"
|
||||
? path.join(paths(account).base, "signing")
|
||||
: path.join(getClaudeHome(), "agenthub-signing");
|
||||
}
|
||||
const laneSignFile = (account, laneId) =>
|
||||
path.join(signingDir(account), `lane-${Number(laneId)}.gitconfig`);
|
||||
|
||||
/** `[{key, value}]` of the `includeIf.*.path` lines naming one of OUR files. */
|
||||
async function ourIncludes(account) {
|
||||
const { git } = require("./worktree");
|
||||
const out = await git(homeOf(account), [
|
||||
"config",
|
||||
"--global",
|
||||
"--null",
|
||||
"--get-regexp",
|
||||
"^includeif\\..*\\.path$",
|
||||
]).then(
|
||||
(r) => r.stdout,
|
||||
() => ""
|
||||
);
|
||||
const dir = signingDir(account);
|
||||
const rows = [];
|
||||
// `--null`: "key\nvalue\0" — a git dir or a path may hold spaces.
|
||||
for (const rec of out.split("\0")) {
|
||||
const at = rec.indexOf("\n");
|
||||
if (at <= 0) continue;
|
||||
const key = rec.slice(0, at);
|
||||
const value = rec.slice(at + 1);
|
||||
if (path.dirname(value) === dir && LANE_SIGN_RE.test(path.basename(value))) {
|
||||
rows.push({ key, value });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Take every include of `file` out of `~/.gitconfig` (the user's own includes stay). */
|
||||
async function dropIncludes(account, file) {
|
||||
const { git } = require("./worktree");
|
||||
for (const r of await ourIncludes(account)) {
|
||||
if (r.value !== file) continue;
|
||||
await git(homeOf(account), [
|
||||
"config",
|
||||
"--global",
|
||||
"--fixed-value",
|
||||
"--unset-all",
|
||||
r.key,
|
||||
r.value,
|
||||
]).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** The key file a workspace file signs with (its `signingkey` line), or null. */
|
||||
function laneSignKeyFile(account, file) {
|
||||
const text = readText(account, file) || "";
|
||||
const m = /^\s*signingkey\s*=\s*"?(.*?)"?\s*$/m.exec(text);
|
||||
return m ? m[1].replace(/\\(["\\])/g, "$1") : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which workspaces sign their commits, and with which key:
|
||||
* `[{laneId, key}]` (`key` null when the key file it names is gone).
|
||||
*/
|
||||
async function laneSigningList(account) {
|
||||
const keys = currentKeys(account);
|
||||
const seen = new Map();
|
||||
for (const r of await ourIncludes(account)) {
|
||||
const laneId = Number(LANE_SIGN_RE.exec(path.basename(r.value))[1]);
|
||||
if (seen.has(laneId)) continue;
|
||||
const keyFile = laneSignKeyFile(account, r.value);
|
||||
seen.set(laneId, keys.find((k) => k.file === keyFile)?.id ?? null);
|
||||
}
|
||||
return [...seen].map(([laneId, key]) => ({ laneId, key })).sort((a, b) => a.laneId - b.laneId);
|
||||
}
|
||||
|
||||
const gitQuote = (v) => `"${String(v).replace(/(["\\])/g, "\\$1")}"`;
|
||||
|
||||
/**
|
||||
* Sign the commits of workspace `laneId` (folder `cwd`) with key `id`, or stop
|
||||
* (`id` null). The git dirs come from git itself, run in the workspace's
|
||||
* folder as its account — never from the request. ENOREPO when the folder is
|
||||
* not a git repository.
|
||||
*/
|
||||
async function setLaneSigning(account, { laneId, cwd, id = null }) {
|
||||
const n = Number(laneId);
|
||||
if (!Number.isSafeInteger(n) || n <= 0) throw err("ENOTFOUND", "no such workspace");
|
||||
const file = laneSignFile(account, n);
|
||||
await dropIncludes(account, file);
|
||||
if (id == null) {
|
||||
removeFile(account, file);
|
||||
return;
|
||||
}
|
||||
const k = currentKeys(account).find((x) => x.id === id);
|
||||
if (!k) throw err("ENOKEY", "no such SSH key");
|
||||
const { git } = require("./worktree");
|
||||
const rev = (args) => git(cwd, ["rev-parse", ...args]).then((r) => r.stdout.trim());
|
||||
let gitDir;
|
||||
let commonDir;
|
||||
try {
|
||||
gitDir = await rev(["--absolute-git-dir"]);
|
||||
commonDir = await rev(["--path-format=absolute", "--git-common-dir"]);
|
||||
} catch {
|
||||
throw err("ENOREPO", "this workspace's folder is not a git repository");
|
||||
}
|
||||
writeText(
|
||||
account,
|
||||
file,
|
||||
`# Written by AgentHub: commit signing for workspace ${n} (Settings → Git).\n` +
|
||||
`[gpg]\n\tformat = ssh\n[user]\n\tsigningkey = ${gitQuote(k.file)}\n` +
|
||||
`[commit]\n\tgpgsign = true\n[tag]\n\tgpgsign = false\n`,
|
||||
{ secret: false }
|
||||
);
|
||||
const add = (pattern) =>
|
||||
git(homeOf(account), ["config", "--global", "--add", `includeIf.gitdir:${pattern}.path`, file]);
|
||||
await add(gitDir);
|
||||
// The worktrees this workspace's sessions make (`claude --worktree=ah-<id>-…`).
|
||||
await add(path.join(commonDir, "worktrees", `ah-${n}-*`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete one key — `{id}`, or the DEFAULT key of `{type}` — or every key when
|
||||
* neither is given; false when there was none. A login naming a deleted key
|
||||
@@ -335,6 +753,18 @@ async function removeSshKey(account, which) {
|
||||
id != null ? k.id === id : type != null ? k.id === KEY_FILES[type] : true
|
||||
);
|
||||
if (!gone.length) return false;
|
||||
// Commits signed with a key about to go would all fail: stop signing first —
|
||||
// account-wide, and in every workspace that signs with it.
|
||||
const signingKey = await globalGet(account, "user.signingkey");
|
||||
if (signingKey && gone.some((k) => k.file === signingKey)) {
|
||||
await setSigning(account, { id: null }).catch(() => {});
|
||||
}
|
||||
const goneIds = new Set(gone.map((k) => k.id));
|
||||
for (const l of await laneSigningList(account)) {
|
||||
if (goneIds.has(l.key)) {
|
||||
await setLaneSigning(account, { laneId: l.laneId, id: null }).catch(() => {});
|
||||
}
|
||||
}
|
||||
for (const k of gone) {
|
||||
removeFile(account, k.file);
|
||||
removeFile(account, `${k.file}.pub`);
|
||||
@@ -1133,6 +1563,12 @@ module.exports = {
|
||||
sshKeyInfo,
|
||||
sshKeysInfo,
|
||||
generateSshKey,
|
||||
importSshKey,
|
||||
parsePrivateKey,
|
||||
signingState,
|
||||
laneSigningList,
|
||||
setLaneSigning,
|
||||
setSigning,
|
||||
removeSshKey,
|
||||
terminalSshCommand,
|
||||
terminalSshState,
|
||||
|
||||
@@ -49,6 +49,8 @@ const RUN_ID_RE = /^agenthub-lane-(\d+)(?:-([1-9]))?$/;
|
||||
/** Highest pane slot a lane may use: ten sessions per workspace, 0–9 (the
|
||||
* Workspace groups them into tabs of up to four split panes each). */
|
||||
const MAX_PANE_SLOT = 9;
|
||||
// Lines a fullscreen `claude` scrolls per wheel report (see spawnRun's env).
|
||||
const CLAUDE_SCROLL_SPEED = "5";
|
||||
const EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]);
|
||||
// `auto` is the CLI's own default-ish mode (`claude --permission-mode auto`):
|
||||
// it asks only for what its classifier reads as risky. `default` is kept for
|
||||
@@ -663,6 +665,15 @@ function spawnRun(args) {
|
||||
// (routes/sessions.js sessionOutline). A user's settings.json `env` still
|
||||
// wins, so `"0"` there turns them back off.
|
||||
...(entry.launcher === "claude" ? { CLAUDE_CODE_ENABLE_TODO_TOOLS: "1" } : {}),
|
||||
// Fullscreen claude scrolls by itself, and through tmux it cannot tell it
|
||||
// runs in xterm.js (XTVERSION answers tmux), so it takes ONE line per wheel
|
||||
// report — while xterm.js sends one report per notch (and damps a trackpad
|
||||
// ×0.3). A notch moved a single line. CLAUDE_CODE_SCROLL_SPEED is Claude's
|
||||
// own multiplier; the dashboard's env or the user's settings.json `env`
|
||||
// (or /config → Scroll speed) still wins.
|
||||
...(entry.launcher === "claude" && !process.env.CLAUDE_CODE_SCROLL_SPEED
|
||||
? { CLAUDE_CODE_SCROLL_SPEED: CLAUDE_SCROLL_SPEED }
|
||||
: {}),
|
||||
// No CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN: an inline claude duplicated its
|
||||
// output whenever the pane narrowed (tmux re-wraps the full-width rules and
|
||||
// pushes lines into history its redraw cannot clear). The user's own
|
||||
|
||||
+7
-1
@@ -340,10 +340,16 @@ function broadcastAudience(type, data) {
|
||||
* the role-based rules above. Pure (no DB): asked for every client. A change
|
||||
* to a member's OWN Claude Code config (`cc_config_changed` with `ownerId`,
|
||||
* routes/cc-config.js) names their files: it reaches that member and nobody
|
||||
* else, an admin or analyst included.
|
||||
* else, an admin or analyst included. A chat frame (`chat_message`,
|
||||
* `chat_room`) reaches exactly the users in its `to`.
|
||||
*/
|
||||
function exactAudience(type, data) {
|
||||
const d = data && typeof data === "object" ? data : {};
|
||||
// Chat (server/lib/chat.js): only the room's members, whatever their role —
|
||||
// an admin reads no one else's chat. No list = nobody.
|
||||
if (type === "chat_message" || type === "chat_room") {
|
||||
return new Set(Array.isArray(d.to) ? d.to : []);
|
||||
}
|
||||
if (type === "cc_config_changed" && d.ownerId != null) return new Set([d.ownerId]);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* @file Resumable uploads for the File Explorer: an upload's bytes are sent in
|
||||
* parts and kept on the HOST's disk under
|
||||
* `<data dir>/upload-staging/<user key>/<id>/` (0700/0600; never in a member's
|
||||
* home), so a restart of the dashboard — an update, a manual restart, a crash —
|
||||
* only costs the part in flight: the browser asks how much arrived (`status`)
|
||||
* and sends the rest. Only when every byte is here is the file written where
|
||||
* it goes, by the SAME `upload`/`uploadBatch` code (and guards) as a one-shot
|
||||
* upload, reading the staged file instead of the request (`commit`).
|
||||
*
|
||||
* `<id>` is the browser's own random name for one upload; ids live per user
|
||||
* key, so one user can never read, extend or commit another's. A part must
|
||||
* start exactly where the staged bytes end (`EOFFSET` with `received`), one
|
||||
* request per id at a time (`EBUSY`), never past the `total` announced first.
|
||||
* A committed upload keeps its answer (`result.json`) and drops its bytes, so
|
||||
* a commit whose answer a restart swallowed is answered again, never written
|
||||
* twice; a failed commit (a taken name, say) keeps the bytes for a retry with
|
||||
* another `conflict`. At most `MAX_OPEN` unfinished uploads per user; anything
|
||||
* untouched for `KEEP_MS` is swept.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { Transform } = require("node:stream");
|
||||
const { pipeline } = require("node:stream/promises");
|
||||
|
||||
const { getDataDir } = require("./claude-home");
|
||||
|
||||
/** The largest upload (the browser sends a whole file — or a batch — under one id). */
|
||||
const TOTAL_MAX = 512 * 1024 * 1024;
|
||||
/** The largest part one request may carry. */
|
||||
const PART_MAX = 16 * 1024 * 1024;
|
||||
const MAX_OPEN = 8;
|
||||
const KEEP_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const ID_RE = /^[A-Za-z0-9_-]{16,64}$/;
|
||||
const KEY_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
||||
|
||||
const bad = (code, message, extra = {}) => Object.assign(new Error(message), { code, ...extra });
|
||||
|
||||
/** Ids (`<key>/<id>`) a request in THIS process is working on. */
|
||||
const busy = new Set();
|
||||
|
||||
const root = () => path.join(getDataDir(), "upload-staging");
|
||||
|
||||
function dirOf(userKey, id) {
|
||||
const key = String(userKey ?? "");
|
||||
if (!KEY_RE.test(key)) throw bad("EBADBODY", "no user for this upload");
|
||||
if (typeof id !== "string" || !ID_RE.test(id)) {
|
||||
throw bad("EBADBODY", "an upload id is 16–64 letters, digits, '-' or '_'");
|
||||
}
|
||||
return { key, id, dir: path.join(root(), key, id), lock: `${key}/${id}` };
|
||||
}
|
||||
|
||||
const dataFile = (d) => path.join(d.dir, "data");
|
||||
const metaFile = (d) => path.join(d.dir, "meta.json");
|
||||
const resultFile = (d) => path.join(d.dir, "result.json");
|
||||
|
||||
function readJson(file) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(file, value) {
|
||||
const tmp = `${file}.tmp`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 });
|
||||
fs.renameSync(tmp, file);
|
||||
}
|
||||
|
||||
const sizeOf = (d) => {
|
||||
try {
|
||||
return fs.statSync(dataFile(d)).size;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
function int(v, name, max) {
|
||||
const n = typeof v === "string" && /^\d+$/.test(v) ? Number(v) : v;
|
||||
if (!Number.isSafeInteger(n) || n < 0 || n > max) throw bad("EBADBODY", `bad ${name}`);
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Drop uploads untouched for KEEP_MS (every user's — the store is one). */
|
||||
function sweep(now = Date.now()) {
|
||||
let keys = [];
|
||||
try {
|
||||
keys = fs.readdirSync(root());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const key of keys) {
|
||||
let ids = [];
|
||||
try {
|
||||
ids = fs.readdirSync(path.join(root(), key));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const id of ids) {
|
||||
if (busy.has(`${key}/${id}`)) continue;
|
||||
const dir = path.join(root(), key, id);
|
||||
try {
|
||||
if (now - fs.statSync(dir).mtimeMs > KEEP_MS)
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* gone meanwhile */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Unfinished uploads of `key` (a committed one keeps only its answer). */
|
||||
function openCount(key) {
|
||||
let ids = [];
|
||||
try {
|
||||
ids = fs.readdirSync(path.join(root(), key));
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
return ids.filter((id) => !fs.existsSync(path.join(root(), key, id, "result.json"))).length;
|
||||
}
|
||||
|
||||
/** Touch the folder so `sweep` counts from the last part, not the first. */
|
||||
function touch(d) {
|
||||
const now = new Date();
|
||||
try {
|
||||
fs.utimesSync(d.dir, now, now);
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
|
||||
/** `{received, total, done}` of one upload (`received: 0`, `total: null` when unknown). */
|
||||
function status(userKey, id) {
|
||||
const d = dirOf(userKey, id);
|
||||
const meta = readJson(metaFile(d));
|
||||
if (!meta) return { received: 0, total: null, done: false };
|
||||
const done = fs.existsSync(resultFile(d));
|
||||
return { received: done ? meta.total : sizeOf(d), total: meta.total, done };
|
||||
}
|
||||
|
||||
/**
|
||||
* Append the request `stream` at `offset` of upload `id` (of `total` bytes in
|
||||
* all). Returns `{received, total}`.
|
||||
*/
|
||||
async function putPart(userKey, id, { offset, total, stream }) {
|
||||
const d = dirOf(userKey, id);
|
||||
const at = int(offset, "offset", TOTAL_MAX);
|
||||
const all = int(total, "total", TOTAL_MAX);
|
||||
if (busy.has(d.lock)) throw bad("EBUSY", "this upload is already receiving a part");
|
||||
busy.add(d.lock);
|
||||
try {
|
||||
let meta = readJson(metaFile(d));
|
||||
if (!meta) {
|
||||
sweep();
|
||||
if (openCount(d.key) >= MAX_OPEN) {
|
||||
throw bad("ETOOMANY", `at most ${MAX_OPEN} unfinished uploads at a time`);
|
||||
}
|
||||
fs.mkdirSync(d.dir, { recursive: true, mode: 0o700 });
|
||||
meta = { total: all, createdAt: new Date().toISOString() };
|
||||
writeJson(metaFile(d), meta);
|
||||
}
|
||||
if (meta.total !== all) throw bad("EBADBODY", `this upload is ${meta.total} bytes`);
|
||||
if (fs.existsSync(resultFile(d))) return { received: all, total: all };
|
||||
const have = sizeOf(d);
|
||||
if (at !== have) {
|
||||
throw bad("EOFFSET", `this upload has ${have} bytes, not ${at}`, { received: have });
|
||||
}
|
||||
const room = Math.min(PART_MAX, all - have);
|
||||
let n = 0;
|
||||
const cap = new Transform({
|
||||
transform(chunk, _enc, cb) {
|
||||
n += chunk.length;
|
||||
if (n > room) return cb(bad("ETOOBIG", "this part goes past the upload's end"));
|
||||
cb(null, chunk);
|
||||
},
|
||||
});
|
||||
// A part cut short leaves what arrived: the next one starts from there.
|
||||
await pipeline(stream, cap, fs.createWriteStream(dataFile(d), { flags: "a", mode: 0o600 }));
|
||||
touch(d);
|
||||
return { received: sizeOf(d), total: all };
|
||||
} finally {
|
||||
busy.delete(d.lock);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a fully staged upload: `write(stream, size)` is the one-shot upload's
|
||||
* own code, given the staged bytes; it answers `{status, body}`. A commit
|
||||
* already made answers its stored answer again; a failed one keeps the bytes.
|
||||
*/
|
||||
async function commit(userKey, id, write) {
|
||||
const d = dirOf(userKey, id);
|
||||
const meta = readJson(metaFile(d));
|
||||
if (!meta) throw bad("ENOFILE", "no such upload — send it again");
|
||||
if (busy.has(d.lock)) throw bad("EBUSY", "this upload is still being written");
|
||||
busy.add(d.lock);
|
||||
try {
|
||||
const stored = readJson(resultFile(d));
|
||||
if (stored) return stored;
|
||||
const have = sizeOf(d);
|
||||
if (have !== meta.total) {
|
||||
throw bad("EINCOMPLETE", `this upload has ${have} of ${meta.total} bytes`, {
|
||||
received: have,
|
||||
});
|
||||
}
|
||||
const stream = fs.createReadStream(dataFile(d));
|
||||
let out;
|
||||
try {
|
||||
out = await write(stream, have);
|
||||
} finally {
|
||||
stream.destroy();
|
||||
}
|
||||
writeJson(resultFile(d), out);
|
||||
fs.rmSync(dataFile(d), { force: true });
|
||||
touch(d);
|
||||
return out;
|
||||
} finally {
|
||||
busy.delete(d.lock);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget one upload (the browser stopped it, or has its answer). Also while a
|
||||
* part is still arriving: a Stop drops the request, and the part writes on
|
||||
* into a file no longer listed — never refused, or the stopped upload would
|
||||
* hold one of MAX_OPEN for a day.
|
||||
*/
|
||||
function drop(userKey, id) {
|
||||
const d = dirOf(userKey, id);
|
||||
fs.rmSync(d.dir, { recursive: true, force: true });
|
||||
return { dropped: true };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
status,
|
||||
putPart,
|
||||
commit,
|
||||
drop,
|
||||
sweep,
|
||||
root,
|
||||
TOTAL_MAX,
|
||||
PART_MAX,
|
||||
MAX_OPEN,
|
||||
KEEP_MS,
|
||||
};
|
||||
@@ -76,7 +76,8 @@ module.exports = combine(
|
||||
overrideCore,
|
||||
overridePricingAlerts,
|
||||
overrideOps,
|
||||
gitRefs
|
||||
gitRefs,
|
||||
require("./openapi-extra/chat")
|
||||
);
|
||||
// The branch & tag writes join the git action entry in place.
|
||||
gitRefs.extend(module.exports);
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
* - POST /api/me/avatar/url (Auth) — the picture as a link, redirected to
|
||||
* - GET/PATCH /api/users/{id} (Auth) — the Users page's Edit user form (its `quota` too)
|
||||
* - PUT /api/users/quota-default, GET /api/me/quota (Auth) — spend quotas per user
|
||||
* - GET /api/users/{id}/avatar (Auth) — one account's picture (admin/analyst)
|
||||
* - GET /api/users/{id}/avatar (Auth) — one account's picture (every account)
|
||||
* - GET /api/users/tmux, POST /api/users/{id}/tmux-restart, POST /api/users/tmux-restart-idle
|
||||
* (Auth) — each member's own tmux server and an admin's restart of it (S3)
|
||||
* Exports `{ tags, schemas, paths }`, combined into the spec by
|
||||
@@ -731,12 +731,11 @@ const paths = {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
operationId: "getUserAvatar",
|
||||
summary: "One account's picture, for the sidebar's owner groups (admin/analyst)",
|
||||
summary: "One account's picture, for owner groups and System Health (every signed-in account)",
|
||||
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
|
||||
responses: {
|
||||
200: { description: "The image (PNG, JPEG, GIF or WebP)" },
|
||||
302: { description: "Redirect to that account's avatar link" },
|
||||
403: err("EADMIN — a developer"),
|
||||
404: err("ENOAVATAR"),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* @file OpenAPI fragment for `/api/chat` (server/routes/chat.js): chat between
|
||||
* the users of a server-mode install — direct chats and groups, text and
|
||||
* files. Every route acts on the caller's own rooms only (404 otherwise, an
|
||||
* admin included); local mode answers `400 ELOCALMODE`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const err = { $ref: "#/components/schemas/ErrorResponse" };
|
||||
const json = (schema) => ({ content: { "application/json": { schema } } });
|
||||
const fail = (codes) =>
|
||||
Object.fromEntries(codes.map((c) => [String(c), { description: "Refused", ...json(err) }]));
|
||||
const idParam = { name: "id", in: "path", required: true, schema: { type: "integer" } };
|
||||
|
||||
const tags = [
|
||||
{
|
||||
name: "Chat",
|
||||
description:
|
||||
"Chat between users (server mode): direct chats and groups in the pet's window. Only a room's members read it.",
|
||||
},
|
||||
];
|
||||
|
||||
const schemas = {
|
||||
ChatFile: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", example: "9f0c1b2a3d4e5f60718293a4b5c6d7e8" },
|
||||
name: { type: "string", example: "screenshot.png" },
|
||||
type: { type: "string", example: "image/png" },
|
||||
size: { type: "integer", example: 20480 },
|
||||
},
|
||||
},
|
||||
ChatMessage: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "integer", example: 42 },
|
||||
roomId: { type: "integer", example: 3 },
|
||||
userId: { type: "integer", nullable: true, example: 2 },
|
||||
kind: {
|
||||
type: "string",
|
||||
enum: ["text", "system"],
|
||||
description:
|
||||
"`system`: a group change — body `created`, `renamed`, `left`, `added:<ids>` or `removed:<ids>`.",
|
||||
},
|
||||
body: { type: "string", example: "Chào cả nhóm" },
|
||||
createdAt: { type: "string", format: "date-time" },
|
||||
files: { type: "array", items: { $ref: "#/components/schemas/ChatFile" } },
|
||||
},
|
||||
},
|
||||
ChatRoom: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "integer", example: 3 },
|
||||
kind: { type: "string", enum: ["dm", "group"] },
|
||||
name: { type: "string", description: "A group's name; empty for a direct chat." },
|
||||
createdBy: { type: "integer", nullable: true },
|
||||
createdAt: { type: "string", format: "date-time" },
|
||||
lastMessageAt: { type: "string", format: "date-time", nullable: true },
|
||||
members: { type: "array", items: { type: "integer" } },
|
||||
lastMessage: { allOf: [{ $ref: "#/components/schemas/ChatMessage" }], nullable: true },
|
||||
lastReadId: { type: "integer" },
|
||||
unread: { type: "integer", description: "Messages of others after `lastReadId`." },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const roomResp = json({
|
||||
type: "object",
|
||||
properties: { room: { $ref: "#/components/schemas/ChatRoom" } },
|
||||
});
|
||||
|
||||
const paths = {
|
||||
"/api/chat/people": {
|
||||
get: {
|
||||
tags: ["Chat"],
|
||||
summary: "Users the caller can chat with",
|
||||
description: "Every enabled account but the caller: id, username, display name, avatar time.",
|
||||
responses: {
|
||||
200: {
|
||||
description: "People",
|
||||
...json({
|
||||
type: "object",
|
||||
properties: {
|
||||
people: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "integer" },
|
||||
username: { type: "string" },
|
||||
displayName: { type: "string", nullable: true },
|
||||
avatarAt: { type: "string", nullable: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
...fail([400]),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/chat/rooms": {
|
||||
get: {
|
||||
tags: ["Chat"],
|
||||
summary: "The caller's rooms",
|
||||
description:
|
||||
"Most recently active first, each with its members, newest message and unread count.",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Rooms",
|
||||
...json({
|
||||
type: "object",
|
||||
properties: {
|
||||
rooms: { type: "array", items: { $ref: "#/components/schemas/ChatRoom" } },
|
||||
},
|
||||
}),
|
||||
},
|
||||
...fail([400]),
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["Chat"],
|
||||
summary: "Open a direct chat or create a group",
|
||||
description:
|
||||
"`{kind: 'dm', userId}` answers the one direct chat of the pair (201 when made now, 200 when it existed); `{kind: 'group', name, userIds}` makes a group of the caller plus `userIds`. Broadcasts `chat_room` to the members.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
...json({
|
||||
type: "object",
|
||||
required: ["kind"],
|
||||
properties: {
|
||||
kind: { type: "string", enum: ["dm", "group"] },
|
||||
userId: { type: "integer" },
|
||||
name: { type: "string", maxLength: 80 },
|
||||
userIds: { type: "array", items: { type: "integer" } },
|
||||
},
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: { description: "The existing direct chat", ...roomResp },
|
||||
201: { description: "Created", ...roomResp },
|
||||
...fail([400]),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/chat/rooms/{id}": {
|
||||
patch: {
|
||||
tags: ["Chat"],
|
||||
summary: "Rename a group, add or remove members",
|
||||
description: "Only the group's creator (`403 EFORBIDDEN`). Removed users are told too.",
|
||||
parameters: [idParam],
|
||||
requestBody: {
|
||||
required: true,
|
||||
...json({
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
add: { type: "array", items: { type: "integer" } },
|
||||
remove: { type: "array", items: { type: "integer" } },
|
||||
},
|
||||
}),
|
||||
},
|
||||
responses: { 200: { description: "Updated", ...roomResp }, ...fail([400, 403, 404]) },
|
||||
},
|
||||
},
|
||||
"/api/chat/rooms/{id}/leave": {
|
||||
post: {
|
||||
tags: ["Chat"],
|
||||
summary: "Leave a group",
|
||||
description: "The last member out deletes the group and its files.",
|
||||
parameters: [idParam],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Left",
|
||||
...json({ type: "object", properties: { ok: { type: "boolean" } } }),
|
||||
},
|
||||
...fail([400, 404]),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/chat/rooms/{id}/messages": {
|
||||
get: {
|
||||
tags: ["Chat"],
|
||||
summary: "A page of a room's messages",
|
||||
description:
|
||||
"Oldest first: the newest `limit` (default 50, max 200), or those before `before`.",
|
||||
parameters: [
|
||||
idParam,
|
||||
{ name: "before", in: "query", schema: { type: "integer" } },
|
||||
{ name: "limit", in: "query", schema: { type: "integer" } },
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Messages",
|
||||
...json({
|
||||
type: "object",
|
||||
properties: {
|
||||
messages: { type: "array", items: { $ref: "#/components/schemas/ChatMessage" } },
|
||||
hasMore: { type: "boolean" },
|
||||
},
|
||||
}),
|
||||
},
|
||||
...fail([400, 404]),
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["Chat"],
|
||||
summary: "Post a message",
|
||||
description:
|
||||
"JSON `{body}` or multipart `body` + up to 10 `files` of ≤ 25 MB. Broadcasts `chat_message` to the room's members.",
|
||||
parameters: [idParam],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { type: "object", properties: { body: { type: "string", maxLength: 8000 } } },
|
||||
},
|
||||
"multipart/form-data": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
body: { type: "string" },
|
||||
files: { type: "array", items: { type: "string", format: "binary" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
201: {
|
||||
description: "Posted",
|
||||
...json({
|
||||
type: "object",
|
||||
properties: { message: { $ref: "#/components/schemas/ChatMessage" } },
|
||||
}),
|
||||
},
|
||||
...fail([400, 404, 413]),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/chat/rooms/{id}/read": {
|
||||
post: {
|
||||
tags: ["Chat"],
|
||||
summary: "Mark a room read up to a message",
|
||||
parameters: [idParam],
|
||||
requestBody: {
|
||||
required: true,
|
||||
...json({ type: "object", required: ["upTo"], properties: { upTo: { type: "integer" } } }),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Read mark",
|
||||
...json({ type: "object", properties: { lastReadId: { type: "integer" } } }),
|
||||
},
|
||||
...fail([400, 404]),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/chat/rooms/{id}/files/{fileId}": {
|
||||
get: {
|
||||
tags: ["Chat"],
|
||||
summary: "One file of a room",
|
||||
description:
|
||||
"Members only. Served under a `sandbox` CSP and `nosniff`; PNG/JPEG/GIF/WebP inline, anything else as an attachment.",
|
||||
parameters: [
|
||||
idParam,
|
||||
{ name: "fileId", in: "path", required: true, schema: { type: "string" } },
|
||||
],
|
||||
responses: {
|
||||
200: { description: "The file's bytes", content: { "application/octet-stream": {} } },
|
||||
...fail([400, 404]),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = { tags, schemas, paths };
|
||||
@@ -21,7 +21,7 @@ const tags = [
|
||||
];
|
||||
|
||||
const errorCodes =
|
||||
"400 EBADPATH/EBADNAME, 403 ESECRET/EFOREIGN/EREADONLY (another user's home)/EBADORIGIN, 404 ENOFILE/ENOHOME, 409 EEXISTS (with `error.names`)/EUPGRADE/EMEMBERGONE, 413 ETOOBIG.";
|
||||
"400 EBADPATH/EBADNAME, 403 ESECRET/EFOREIGN/EREADONLY (another user's home)/EBADORIGIN, 404 ENOFILE/ENOHOME, 409 EEXISTS (with `error.names`)/EUPGRADE/EMEMBERGONE, 413 ETOOBIG; a resumable upload adds 409 EOFFSET/EINCOMPLETE (with `error.received`)/EBUSY and 429 ETOOMANY.";
|
||||
|
||||
const schemas = {
|
||||
FsEntry: {
|
||||
@@ -141,12 +141,109 @@ const OPS = [
|
||||
parameters: [
|
||||
{ name: "path", in: "query", required: true, schema: { type: "string" } },
|
||||
{ name: "conflict", in: "query", schema: conflict },
|
||||
{
|
||||
name: "staged",
|
||||
in: "query",
|
||||
schema: { type: "string" },
|
||||
description:
|
||||
"Write the bytes sent before through `/upload-part` under this upload id instead of a body. A commit made before answers its stored answer again; a failed one keeps the bytes for a retry.",
|
||||
},
|
||||
],
|
||||
body: {
|
||||
required: false,
|
||||
content: { "application/octet-stream": { schema: { type: "string", format: "binary" } } },
|
||||
},
|
||||
status: 201,
|
||||
},
|
||||
],
|
||||
[
|
||||
"get",
|
||||
"/upload-part",
|
||||
"How much of a resumable upload arrived",
|
||||
{
|
||||
opId: "UploadPartStatus",
|
||||
note: "`{received, total, done}` for the caller's own upload id (`received: 0, total: null` when unknown). The browser asks this after the dashboard came back, then sends the rest.",
|
||||
parameters: [
|
||||
{
|
||||
name: "upload",
|
||||
in: "query",
|
||||
required: true,
|
||||
schema: { type: "string", pattern: "^[A-Za-z0-9_-]{16,64}$" },
|
||||
},
|
||||
],
|
||||
ok: {
|
||||
description: "`{received, total, done}`",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
received: { type: "integer" },
|
||||
total: { type: "integer", nullable: true },
|
||||
done: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"post",
|
||||
"/upload-part",
|
||||
"Send one part of a resumable upload (≤ 16 MB a part, 512 MB in all)",
|
||||
{
|
||||
opId: "UploadPart",
|
||||
note: "Raw bytes appended at `offset` to the caller's upload `upload` of `total` bytes, staged on the host (`<data dir>/upload-staging/`) until `/upload` or `/upload-batch` names it with `?staged=`. A part must start where the staged bytes end (409 EOFFSET with `error.received`), one at a time per id (409 EBUSY); a part cut short keeps what arrived. At most 8 unfinished uploads per user (429 ETOOMANY); one untouched for a day is swept.",
|
||||
parameters: [
|
||||
{
|
||||
name: "upload",
|
||||
in: "query",
|
||||
required: true,
|
||||
schema: { type: "string", pattern: "^[A-Za-z0-9_-]{16,64}$" },
|
||||
},
|
||||
{ name: "offset", in: "query", required: true, schema: { type: "integer", minimum: 0 } },
|
||||
{ name: "total", in: "query", required: true, schema: { type: "integer", minimum: 0 } },
|
||||
],
|
||||
body: {
|
||||
required: true,
|
||||
content: { "application/octet-stream": { schema: { type: "string", format: "binary" } } },
|
||||
},
|
||||
status: 201,
|
||||
ok: {
|
||||
description: "`{received, total}`",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: { received: { type: "integer" }, total: { type: "integer" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"delete",
|
||||
"/upload-part",
|
||||
"Forget a resumable upload's staged bytes",
|
||||
{
|
||||
opId: "UploadPartDrop",
|
||||
parameters: [
|
||||
{
|
||||
name: "upload",
|
||||
in: "query",
|
||||
required: true,
|
||||
schema: { type: "string", pattern: "^[A-Za-z0-9_-]{16,64}$" },
|
||||
},
|
||||
],
|
||||
ok: {
|
||||
description: "`{dropped: true}`",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { type: "object", properties: { dropped: { type: "boolean" } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -178,9 +275,16 @@ const OPS = [
|
||||
parameters: [
|
||||
{ name: "dir", in: "query", schema: { type: "string", default: "" } },
|
||||
{ name: "conflict", in: "query", schema: conflict },
|
||||
{
|
||||
name: "staged",
|
||||
in: "query",
|
||||
schema: { type: "string" },
|
||||
description:
|
||||
"Write the bytes sent before through `/upload-part` under this upload id instead of a body. A commit made before answers its stored answer again; a failed one keeps the bytes for a retry.",
|
||||
},
|
||||
],
|
||||
body: {
|
||||
required: true,
|
||||
required: false,
|
||||
content: { "application/octet-stream": { schema: { type: "string", format: "binary" } } },
|
||||
},
|
||||
status: 201,
|
||||
@@ -233,7 +337,7 @@ function operations(prefix, idParam, opPrefix, note) {
|
||||
tags: ["Files"],
|
||||
summary,
|
||||
description: `${note} ${x.note ? `${x.note} ` : ""}${errorCodes}`,
|
||||
operationId: `${opPrefix}${sub.replace(/[/-](\w)/g, (_, c) => c.toUpperCase())}`,
|
||||
operationId: `${opPrefix}${x.opId || sub.replace(/[/-](\w)/g, (_, c) => c.toUpperCase())}`,
|
||||
parameters: [idParam, ...(x.parameters || [])],
|
||||
...(x.body ? { requestBody: x.body } : {}),
|
||||
responses: { [x.status || 200]: x.ok || { description: "Done." } },
|
||||
@@ -459,6 +563,71 @@ const paths = {
|
||||
responses: { 200: { description: "The identity." } },
|
||||
},
|
||||
},
|
||||
"/api/me/git/ssh-key/import": {
|
||||
post: {
|
||||
tags: ["Git identity"],
|
||||
summary:
|
||||
"Import a private key the user already has as a named key; with `sign`, sign commits with it at once",
|
||||
operationId: "importSshKey",
|
||||
requestBody: json(
|
||||
{
|
||||
privateKey: { type: "string", description: "The key file's text (OpenSSH or PEM)." },
|
||||
name: { type: "string", pattern: "^[a-z0-9][a-z0-9-]{0,31}$" },
|
||||
sign: { type: "boolean" },
|
||||
replace: { type: "boolean" },
|
||||
},
|
||||
["privateKey"]
|
||||
),
|
||||
responses: {
|
||||
201: {
|
||||
description:
|
||||
"The identity, plus `imported` (the key's public half) and `signError` (a refused `sign`, else null).",
|
||||
},
|
||||
400: { description: "EBADKEY / EKEYPASSPHRASE / EBADKEYTYPE / EBADKEYNAME" },
|
||||
409: { description: "EEXISTS (the same key, or the name is taken) / ETOOMANYKEYS" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/me/git/signing": {
|
||||
put: {
|
||||
tags: ["Git identity"],
|
||||
summary:
|
||||
"Sign every commit with one of the account's keys (`key`: its id), or stop (`key: null`)",
|
||||
operationId: "setCommitSigning",
|
||||
description:
|
||||
"The account-wide setting (every repository of the account). `clearForeign: true` with `key: null` also takes out a signing setup the user made themselves — the three settings only, never their key file. Per workspace: `PUT /api/me/git/signing/lane`.",
|
||||
requestBody: json({
|
||||
key: { type: "string", nullable: true },
|
||||
replace: { type: "boolean" },
|
||||
clearForeign: { type: "boolean" },
|
||||
}),
|
||||
responses: {
|
||||
200: { description: "The identity; `signing.state` is on, off or foreign." },
|
||||
400: { description: "ENOKEY" },
|
||||
409: { description: "EFOREIGNSIGN — ~/.gitconfig already signs with a key of its own." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/me/git/signing/lane": {
|
||||
put: {
|
||||
tags: ["Git identity"],
|
||||
summary:
|
||||
"Sign ONE workspace's commits with one of the account's keys (`key`: its id), or stop (`key: null`)",
|
||||
description:
|
||||
"Writes a small file of AgentHub's (`lane-<id>.gitconfig`: gpg.format ssh, user.signingkey, commit.gpgsign) and includes it from the account's `~/.gitconfig` with `includeIf \"gitdir:<the workspace's git dir>\"` and `gitdir:<common dir>/worktrees/ah-<id>-*` (its sessions' worktrees) — nothing is written into the repository. The workspace must be one the caller may see whose folder runs as this same account. `GET /api/me/git` answers `laneSigning: [{laneId, key}]`; deleting a key stops every workspace that signs with it.",
|
||||
operationId: "setLaneCommitSigning",
|
||||
requestBody: json({ laneId: { type: "integer" }, key: { type: "string", nullable: true } }, [
|
||||
"laneId",
|
||||
]),
|
||||
responses: {
|
||||
200: { description: "The identity, with `laneSigning`." },
|
||||
400: { description: "ENOKEY" },
|
||||
403: { description: "EFOREIGN — the workspace runs as another account; EHOSTIDENTITY." },
|
||||
404: { description: "ENOTFOUND — no such workspace (or not the caller's)." },
|
||||
409: { description: "ENOREPO — the workspace's folder is not a git repository." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/me/git/ssh-terminal": {
|
||||
put: {
|
||||
tags: ["Git identity"],
|
||||
|
||||
@@ -154,6 +154,14 @@ const paths = {
|
||||
"`1` = only sessions blocked on the user (`awaiting_input_since IS NOT NULL AND status = 'active'`), the UI's Waiting filter. Applied before `limit`/`offset`, so `total` stays exact.",
|
||||
example: "1",
|
||||
},
|
||||
{
|
||||
name: "mine",
|
||||
in: "query",
|
||||
schema: { type: "string", enum: ["1"] },
|
||||
description:
|
||||
"Server mode: `1` = only the caller's OWN sessions, even for an admin or analyst (the pet's count). No-op in local mode.",
|
||||
example: "1",
|
||||
},
|
||||
{
|
||||
name: "from",
|
||||
in: "query",
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* @file chat.js (routes)
|
||||
* @description `/api/chat` — chat between users in server mode (the pet's
|
||||
* window, `client/src/components/Sagi/ChatHub.tsx`): people to chat with,
|
||||
* the caller's rooms, a direct chat or a group, messages (text + files, a
|
||||
* multipart `POST`), read marks and a room's files. Everything acts on the
|
||||
* CALLER's own rooms (`server/lib/chat.js`): a room they are not in is 404,
|
||||
* whatever their role. Local mode has one person and no chat (`400 ELOCALMODE`).
|
||||
* Each change is broadcast as `chat_message` / `chat_room` to exactly the
|
||||
* users concerned (`to`, `scope.exactAudience`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
const express = require("express");
|
||||
const chat = require("../lib/chat");
|
||||
const { isServerMode } = require("../lib/auth");
|
||||
const { sameOriginGuard } = require("../lib/same-origin");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function send(res, e) {
|
||||
const status = e.status || (e.code === "ENOUSER" || e.code === "EBADFIELD" ? 400 : 500);
|
||||
if (status === 500) console.error("[chat]", e);
|
||||
res.status(status).json({ error: { code: e.code || "EINTERNAL", message: e.message } });
|
||||
}
|
||||
|
||||
/** Run `fn(userId)`; refuses local mode and an anonymous request. */
|
||||
const handle = (fn) => (req, res) => {
|
||||
if (!isServerMode() || !req.user) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "ELOCALMODE", message: "chat is for a shared server" } });
|
||||
}
|
||||
try {
|
||||
fn(req.user.id, req, res);
|
||||
} catch (e) {
|
||||
send(res, e);
|
||||
}
|
||||
};
|
||||
|
||||
function tell(type, data, to) {
|
||||
try {
|
||||
require("../websocket").broadcast(type, { ...data, to });
|
||||
} catch {
|
||||
// no socket server (tests): nothing to tell
|
||||
}
|
||||
}
|
||||
|
||||
/** Multipart `files` + `body`, in memory, within the chat's limits. */
|
||||
function upload(req, res, next) {
|
||||
if (!req.is("multipart/form-data")) return next();
|
||||
const multer = require("multer");
|
||||
multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: chat.MAX_FILE_BYTES, files: chat.MAX_FILES },
|
||||
}).array("files")(req, res, (e) => {
|
||||
if (!e) return next();
|
||||
const big = e.code === "LIMIT_FILE_SIZE" || e.code === "LIMIT_FILE_COUNT";
|
||||
return res.status(big ? 413 : 400).json({
|
||||
error: { code: big ? "ETOOBIG" : "EBADUPLOAD", message: e.message },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
router.get(
|
||||
"/people",
|
||||
handle((me, _req, res) => res.json({ people: chat.people(me) }))
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/rooms",
|
||||
handle((me, _req, res) => res.json({ rooms: chat.listRooms(me) }))
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/rooms",
|
||||
sameOriginGuard,
|
||||
handle((me, req, res) => {
|
||||
const b = req.body || {};
|
||||
if (b.kind === "dm") {
|
||||
const { room, created } = chat.openDm(me, b.userId);
|
||||
if (created) tell("chat_room", { roomId: room.id }, room.members);
|
||||
return res.status(created ? 201 : 200).json({ room });
|
||||
}
|
||||
if (b.kind === "group") {
|
||||
const room = chat.createGroup(me, { name: b.name, userIds: b.userIds });
|
||||
tell("chat_room", { roomId: room.id }, room.members);
|
||||
return res.status(201).json({ room });
|
||||
}
|
||||
throw Object.assign(new Error("kind: dm or group"), { code: "EBADFIELD" });
|
||||
})
|
||||
);
|
||||
|
||||
router.patch(
|
||||
"/rooms/:id",
|
||||
sameOriginGuard,
|
||||
handle((me, req, res) => {
|
||||
const b = req.body || {};
|
||||
const { room, notify } = chat.updateGroup(me, req.params.id, {
|
||||
name: b.name,
|
||||
add: b.add,
|
||||
remove: b.remove,
|
||||
});
|
||||
tell("chat_room", { roomId: room.id }, notify);
|
||||
res.json({ room });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/rooms/:id/leave",
|
||||
sameOriginGuard,
|
||||
handle((me, req, res) => {
|
||||
const { notify } = chat.leaveGroup(me, req.params.id);
|
||||
tell("chat_room", { roomId: Number(req.params.id) }, notify);
|
||||
res.json({ ok: true });
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/rooms/:id/messages",
|
||||
handle((me, req, res) =>
|
||||
res.json(chat.messages(me, req.params.id, { before: req.query.before, limit: req.query.limit }))
|
||||
)
|
||||
);
|
||||
|
||||
router.post("/rooms/:id/messages", sameOriginGuard, upload, (req, res) =>
|
||||
handle((me) => {
|
||||
const files = (req.files || []).map((f) => ({
|
||||
// multer reads names as latin1; browsers send UTF-8.
|
||||
name: Buffer.from(f.originalname, "latin1").toString("utf8"),
|
||||
type: f.mimetype,
|
||||
buffer: f.buffer,
|
||||
}));
|
||||
const { message, notify } = chat.post(me, req.params.id, { body: req.body?.body, files });
|
||||
tell("chat_message", { roomId: message.roomId, message }, notify);
|
||||
res.status(201).json({ message });
|
||||
})(req, res)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/rooms/:id/read",
|
||||
sameOriginGuard,
|
||||
handle((me, req, res) => res.json(chat.markRead(me, req.params.id, req.body?.upTo)))
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/rooms/:id/files/:fileId",
|
||||
handle((me, req, res) => {
|
||||
const f = chat.file(me, req.params.id, req.params.fileId);
|
||||
const ascii = f.name.replace(/[^\x20-\x7e]|["\\]/g, "_");
|
||||
res.set({
|
||||
"Content-Type": f.type,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Content-Security-Policy": "sandbox; default-src 'none'",
|
||||
"Cache-Control": "private, max-age=86400",
|
||||
"Content-Disposition": `${f.inline ? "inline" : "attachment"}; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(f.name)}`,
|
||||
});
|
||||
require("../lib/send-file").sendFile(res, f.path);
|
||||
})
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
@@ -9,7 +9,15 @@
|
||||
* SSH key ID — each optionally tied to one key), and the commit author (`user.name` /
|
||||
* `user.email` of the account's global git config — a member's commits fail
|
||||
* without one; a new SSH key is labelled with that email, else
|
||||
* `<username>@smartgift.vn`). The account is the signed-in user's
|
||||
* `<username>@smartgift.vn`), a key the user already has imported as a
|
||||
* named one (`POST /ssh-key/import` — the private text goes in, only its
|
||||
* public half ever comes back), and whether commits are signed with one of
|
||||
* the keys (`PUT /signing`: `gpg.format ssh` + `user.signingkey` +
|
||||
* `commit.gpgsign` in the account's `~/.gitconfig`; a signing key the user
|
||||
* set themselves is replaced only on `replace: true`, or taken out on
|
||||
* `clearForeign: true` — their key file is never touched), or — per workspace —
|
||||
* which of the caller's workspaces sign (`PUT /signing/lane`, an
|
||||
* `includeIf "gitdir:…"` of one small file of ours each). The account is the signed-in user's
|
||||
* own Linux account under S3, else the host's, which only an admin (or local
|
||||
* mode) may change (`EHOSTIDENTITY`). Nothing here takes a path from the
|
||||
* request, and every write passes the same-origin guard.
|
||||
@@ -39,6 +47,12 @@ const STATUS = {
|
||||
ETOOMANYKEYS: 409,
|
||||
EBADALIAS: 400,
|
||||
ENOKEY: 400,
|
||||
EBADKEY: 400,
|
||||
EKEYPASSPHRASE: 400,
|
||||
EFOREIGNSIGN: 409,
|
||||
ENOREPO: 409,
|
||||
ENOTFOUND: 404,
|
||||
EFOREIGN: 403,
|
||||
};
|
||||
|
||||
function fail(res, e) {
|
||||
@@ -61,6 +75,9 @@ async function view(account, username) {
|
||||
// The regions a CodeCommit key ID saved for "every region" covers.
|
||||
codecommitRegions: identity.CODECOMMIT_REGIONS,
|
||||
https: identity.listHttps(account),
|
||||
signing: await identity.signingState(account),
|
||||
// Workspaces whose commits are signed: `[{laneId, key}]`.
|
||||
laneSigning: await identity.laneSigningList(account),
|
||||
author: await identity.getAuthor(account),
|
||||
// What the author email form suggests while none is set.
|
||||
defaultEmail: identity.defaultEmail(username),
|
||||
@@ -106,6 +123,87 @@ router.post("/ssh-key", sameOriginGuard, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Import a private key the user already has: `{privateKey, name?, sign?,
|
||||
* replace?}`. With `sign`, commits are signed with it at once (one step for
|
||||
* the user); `replace` lets that take over a signing key they set themselves.
|
||||
*/
|
||||
router.post("/ssh-key/import", sameOriginGuard, async (req, res) => {
|
||||
try {
|
||||
const account = accountOf(req, { write: true });
|
||||
const key = await identity.importSshKey(account, {
|
||||
privateKey: req.body?.privateKey,
|
||||
name: typeof req.body?.name === "string" ? req.body.name : null,
|
||||
comment: (await identity.getAuthor(account)).email || identity.defaultEmail(usernameOf(req)),
|
||||
});
|
||||
let signError = null;
|
||||
if (req.body?.sign === true && key) {
|
||||
// The key is saved either way; a refused switch is said, not thrown away.
|
||||
await identity
|
||||
.setSigning(account, { id: key.id, replace: req.body?.replace === true })
|
||||
.catch((e) => {
|
||||
signError = { code: e.code || "EINTERNAL", message: e.message };
|
||||
});
|
||||
}
|
||||
res.status(201).json({ ...(await view(account, usernameOf(req))), imported: key, signError });
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
/** Sign commits with key `{key: id}`, or stop (`{key: null}`); `replace` as above. */
|
||||
router.put("/signing", sameOriginGuard, async (req, res) => {
|
||||
try {
|
||||
const account = accountOf(req, { write: true });
|
||||
const id = typeof req.body?.key === "string" && req.body.key ? req.body.key : null;
|
||||
await identity.setSigning(account, {
|
||||
id,
|
||||
replace: req.body?.replace === true,
|
||||
clearForeign: req.body?.clearForeign === true,
|
||||
});
|
||||
res.json(await view(account, usernameOf(req)));
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Sign the commits of ONE workspace with `{laneId, key}` (`key: null` stops).
|
||||
* The workspace must be one the caller may see (scopeGuard, `laneId` in the
|
||||
* body) whose folder runs as this same account — a workspace another account
|
||||
* runs could never read this account's `~/.gitconfig` (EFOREIGN).
|
||||
*/
|
||||
router.put("/signing/lane", sameOriginGuard, async (req, res) => {
|
||||
try {
|
||||
const account = accountOf(req, { write: true });
|
||||
const laneId = Number(req.body?.laneId);
|
||||
const id = typeof req.body?.key === "string" && req.body.key ? req.body.key : null;
|
||||
// Stopping touches only this account's own file, so it needs no workspace:
|
||||
// one removed from the dashboard while it signed can still be turned off.
|
||||
if (id == null && Number.isSafeInteger(laneId) && laneId > 0) {
|
||||
await identity.setLaneSigning(account, { laneId, id: null });
|
||||
return res.json(await view(account, usernameOf(req)));
|
||||
}
|
||||
const lane = Number.isSafeInteger(laneId) ? require("../lib/lanes").getLane(laneId) : null;
|
||||
if (!lane || !require("../lib/scope").canSeeLane(req, laneId)) {
|
||||
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no such workspace" } });
|
||||
}
|
||||
const folder = identity.accountForDir(lane.cwd);
|
||||
if (
|
||||
folder.kind !== account.kind ||
|
||||
(folder.kind === "member" && folder.acct.login !== account.acct.login)
|
||||
) {
|
||||
throw Object.assign(new Error("this workspace runs as another account"), {
|
||||
code: "EFOREIGN",
|
||||
});
|
||||
}
|
||||
await identity.setLaneSigning(account, { laneId, cwd: lane.cwd, id });
|
||||
res.json(await view(account, usernameOf(req)));
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/ssh-key", sameOriginGuard, async (req, res) => {
|
||||
try {
|
||||
const account = accountOf(req, { write: true });
|
||||
|
||||
+63
-18
@@ -8,6 +8,7 @@
|
||||
* GET /api/homes/:who/fs/list|trash|zip|download
|
||||
* POST /api/homes/:who/fs/mkdir|newfile|rename|move|copy|delete|upload|trash/restore|trash/purge
|
||||
* POST /api/homes/:who/fs/upload-batch|upload-check many small files in one request
|
||||
* GET|POST|DELETE /api/homes/:who/fs/upload-part a resumable upload's parts (then `?staged=`)
|
||||
*
|
||||
* `:who` is `me`, or — for an admin — another user's name: read-only
|
||||
* (`403 EREADONLY` on every write) and written to the audit log. The one
|
||||
@@ -24,6 +25,7 @@ const express = require("express");
|
||||
const path = require("node:path");
|
||||
|
||||
const fx = require("../lib/file-explorer");
|
||||
const staging = require("../lib/upload-staging");
|
||||
const homes = require("../lib/homes");
|
||||
const scope = require("../lib/scope");
|
||||
const lanesLib = require("../lib/lanes");
|
||||
@@ -46,12 +48,17 @@ function fail(res, err) {
|
||||
EUPGRADE: 409,
|
||||
EMEMBERGONE: 409,
|
||||
ETOOBIG: 413,
|
||||
EOFFSET: 409,
|
||||
EBUSY: 409,
|
||||
EINCOMPLETE: 409,
|
||||
ETOOMANY: 429,
|
||||
}[err.code] ?? 500;
|
||||
res.status(status).json({
|
||||
error: {
|
||||
code: err.code || "EFS",
|
||||
message: err.message,
|
||||
...(Array.isArray(err.names) ? { names: err.names } : {}),
|
||||
...(Number.isInteger(err.received) ? { received: err.received } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -294,22 +301,57 @@ router.post(
|
||||
TRASH_W
|
||||
)
|
||||
);
|
||||
/**
|
||||
* The body of an upload: the request, or with `?staged=<id>` the bytes sent
|
||||
* before in parts (`upload-staging.js`; see the workspace routes).
|
||||
*/
|
||||
function stagedOr(req, write) {
|
||||
if (req.query.staged === undefined) return write(req, req.headers["content-length"]);
|
||||
req.resume();
|
||||
return staging.commit(userKey(req), req.query.staged, write);
|
||||
}
|
||||
router.get(
|
||||
"/:who/fs/upload-part",
|
||||
handle(async (req, res) => res.json(staging.status(userKey(req), req.query.upload)), W)
|
||||
);
|
||||
router.post(
|
||||
"/:who/fs/upload-part",
|
||||
sameOriginGuard,
|
||||
handle(async (req, res) => {
|
||||
try {
|
||||
res.json(
|
||||
await staging.putPart(userKey(req), req.query.upload, {
|
||||
offset: req.query.offset,
|
||||
total: req.query.total,
|
||||
stream: req,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
req.resume();
|
||||
throw err;
|
||||
}
|
||||
}, W)
|
||||
);
|
||||
router.delete(
|
||||
"/:who/fs/upload-part",
|
||||
sameOriginGuard,
|
||||
handle(async (req, res) => res.json(staging.drop(userKey(req), req.query.upload)), W)
|
||||
);
|
||||
router.post(
|
||||
"/:who/fs/upload",
|
||||
sameOriginGuard,
|
||||
handle(async (req, res, home) => {
|
||||
try {
|
||||
const out = await fx.upload(
|
||||
home.dir,
|
||||
typeof req.query.path === "string" ? req.query.path : "",
|
||||
req,
|
||||
{
|
||||
conflict: req.query.conflict,
|
||||
length: req.headers["content-length"],
|
||||
trash: trashFor(req, home),
|
||||
}
|
||||
);
|
||||
res.status(out.skipped ? 200 : 201).json(out);
|
||||
const r = await stagedOr(req, async (stream, length) => {
|
||||
const out = await fx.upload(
|
||||
home.dir,
|
||||
typeof req.query.path === "string" ? req.query.path : "",
|
||||
stream,
|
||||
{ conflict: req.query.conflict, length, trash: trashFor(req, home) }
|
||||
);
|
||||
return { status: out.skipped ? 200 : 201, body: out };
|
||||
});
|
||||
res.status(r.status).json(r.body);
|
||||
} catch (err) {
|
||||
req.resume();
|
||||
throw err;
|
||||
@@ -321,13 +363,16 @@ router.post(
|
||||
sameOriginGuard,
|
||||
handle(async (req, res, home) => {
|
||||
try {
|
||||
const out = await fx.uploadBatch(
|
||||
home.dir,
|
||||
typeof req.query.dir === "string" ? req.query.dir : "",
|
||||
req,
|
||||
{ conflict: req.query.conflict, trash: trashFor(req, home) }
|
||||
);
|
||||
res.status(201).json(out);
|
||||
const r = await stagedOr(req, async (stream) => ({
|
||||
status: 201,
|
||||
body: await fx.uploadBatch(
|
||||
home.dir,
|
||||
typeof req.query.dir === "string" ? req.query.dir : "",
|
||||
stream,
|
||||
{ conflict: req.query.conflict, trash: trashFor(req, home) }
|
||||
),
|
||||
}));
|
||||
res.status(r.status).json(r.body);
|
||||
} catch (err) {
|
||||
req.resume();
|
||||
throw err;
|
||||
|
||||
+83
-18
@@ -1879,6 +1879,7 @@ router.post("/:id/git/upload", sameOriginGuard, uploadBody, async (req, res) =>
|
||||
// folder's account. `conflict` (fail|skip|keep|replace) settles a taken
|
||||
// name; 409 EEXISTS carries `names` so the browser can ask.
|
||||
const fileExplorer = require("../lib/file-explorer");
|
||||
const uploadStaging = require("../lib/upload-staging");
|
||||
|
||||
function fsFail(res, err) {
|
||||
const status =
|
||||
@@ -1894,12 +1895,17 @@ function fsFail(res, err) {
|
||||
EMEMBERGONE: 409,
|
||||
ETOOBIG: 413,
|
||||
ENOHOME: 404,
|
||||
EOFFSET: 409,
|
||||
EBUSY: 409,
|
||||
EINCOMPLETE: 409,
|
||||
ETOOMANY: 429,
|
||||
}[err.code] ?? 500;
|
||||
res.status(status).json({
|
||||
error: {
|
||||
code: err.code || "EFS",
|
||||
message: err.message,
|
||||
...(Array.isArray(err.names) ? { names: err.names } : {}),
|
||||
...(Number.isInteger(err.received) ? { received: err.received } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -2056,6 +2062,61 @@ router.post("/:id/fs/trash/purge", sameOriginGuard, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* An upload's body: the request itself, or — with `?staged=<id>` — the bytes
|
||||
* this user already sent in parts (`upload-staging.js`), so a restart in the
|
||||
* middle costs only the part in flight. `write(stream, length)` answers
|
||||
* `{status, body}`; a staged commit made before answers its stored answer.
|
||||
*/
|
||||
function stagedOr(req, write) {
|
||||
const id = req.query.staged;
|
||||
if (id === undefined) {
|
||||
return write(req, req.headers["content-length"]);
|
||||
}
|
||||
req.resume();
|
||||
return uploadStaging.commit(fsUserKey(req), id, write);
|
||||
}
|
||||
|
||||
/**
|
||||
* One part of a resumable upload (`?upload=&offset=&total=`, raw bytes); GET = how
|
||||
* much arrived (`{received, total, done}`), DELETE = forget it. The bytes stay
|
||||
* on the host until `/fs/upload` or `/fs/upload-batch` names `?staged=<id>`.
|
||||
*/
|
||||
router.get("/:id/fs/upload-part", async (req, res) => {
|
||||
const t = await gitTargetOr404(req, res);
|
||||
if (!t) return;
|
||||
try {
|
||||
res.json(uploadStaging.status(fsUserKey(req), req.query.upload));
|
||||
} catch (err) {
|
||||
fsFail(res, err);
|
||||
}
|
||||
});
|
||||
router.post("/:id/fs/upload-part", sameOriginGuard, async (req, res) => {
|
||||
const t = await gitTargetOr404(req, res);
|
||||
if (!t) return;
|
||||
try {
|
||||
res.json(
|
||||
await uploadStaging.putPart(fsUserKey(req), req.query.upload, {
|
||||
offset: req.query.offset,
|
||||
total: req.query.total,
|
||||
stream: req,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
req.resume();
|
||||
fsFail(res, err);
|
||||
}
|
||||
});
|
||||
router.delete("/:id/fs/upload-part", sameOriginGuard, async (req, res) => {
|
||||
const t = await gitTargetOr404(req, res);
|
||||
if (!t) return;
|
||||
try {
|
||||
res.json(uploadStaging.drop(fsUserKey(req), req.query.upload));
|
||||
} catch (err) {
|
||||
fsFail(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Upload one file, streamed (no body parser buffers it), to `?path=` — any
|
||||
* missing sub-folders are made (a folder upload sends each file with its
|
||||
@@ -2065,17 +2126,17 @@ router.post("/:id/fs/upload", sameOriginGuard, async (req, res) => {
|
||||
const t = await gitTargetOr404(req, res);
|
||||
if (!t) return;
|
||||
try {
|
||||
const out = await fileExplorer.upload(
|
||||
t.cwd,
|
||||
typeof req.query.path === "string" ? req.query.path : "",
|
||||
req,
|
||||
{
|
||||
conflict: req.query.conflict,
|
||||
length: req.headers["content-length"],
|
||||
trash: fsTrashFor(req, t),
|
||||
}
|
||||
);
|
||||
res.status(out.skipped ? 200 : 201).json(out);
|
||||
const write = async (stream, length) => {
|
||||
const out = await fileExplorer.upload(
|
||||
t.cwd,
|
||||
typeof req.query.path === "string" ? req.query.path : "",
|
||||
stream,
|
||||
{ conflict: req.query.conflict, length, trash: fsTrashFor(req, t) }
|
||||
);
|
||||
return { status: out.skipped ? 200 : 201, body: out };
|
||||
};
|
||||
const r = await stagedOr(req, write);
|
||||
res.status(r.status).json(r.body);
|
||||
} catch (err) {
|
||||
req.resume();
|
||||
fsFail(res, err);
|
||||
@@ -2091,13 +2152,17 @@ router.post("/:id/fs/upload-batch", sameOriginGuard, async (req, res) => {
|
||||
const t = await gitTargetOr404(req, res);
|
||||
if (!t) return;
|
||||
try {
|
||||
const out = await fileExplorer.uploadBatch(
|
||||
t.cwd,
|
||||
typeof req.query.dir === "string" ? req.query.dir : "",
|
||||
req,
|
||||
{ conflict: req.query.conflict, trash: fsTrashFor(req, t) }
|
||||
);
|
||||
res.status(201).json(out);
|
||||
const write = async (stream) => ({
|
||||
status: 201,
|
||||
body: await fileExplorer.uploadBatch(
|
||||
t.cwd,
|
||||
typeof req.query.dir === "string" ? req.query.dir : "",
|
||||
stream,
|
||||
{ conflict: req.query.conflict, trash: fsTrashFor(req, t) }
|
||||
),
|
||||
});
|
||||
const r = await stagedOr(req, write);
|
||||
res.status(r.status).json(r.body);
|
||||
} catch (err) {
|
||||
req.resume();
|
||||
fsFail(res, err);
|
||||
|
||||
@@ -160,6 +160,13 @@ router.get("/", (req, res) => {
|
||||
);
|
||||
params.push(Number(lane), Number(lane));
|
||||
}
|
||||
// `mine=1` = the requesting user's OWN sessions whatever their role (the
|
||||
// pet's count): an admin/analyst otherwise sees every user's. "1" locally.
|
||||
if (req.query.mine === "1") {
|
||||
const o = require("../lib/scope").ownSessionWhere(req, "s.id", "s.lane_id");
|
||||
where.push(o.where);
|
||||
params.push(...o.params);
|
||||
}
|
||||
// `awaiting=1` = only sessions blocked on the user (an active session with
|
||||
// `awaiting_input_since`), the UI's "Waiting" filter, done before the limit.
|
||||
if (req.query.awaiting === "1") {
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
* tool the accounts it may run on (pin, group, quota, its own 5 h / 7 d share
|
||||
* of each — `accountUsage` with `ownerId`) and the ones it actually ran on;
|
||||
* `GET /:id/avatar` its picture (server/lib/avatars.js), for the sidebar's
|
||||
* owner groups — staff only like the rest of `/users`.
|
||||
* owner groups and System Health — the one `/users` read a developer may make
|
||||
* too (`EVERYONE_READS` in server/lib/auth.js).
|
||||
*
|
||||
* The Users page's account changes — `POST /` (create), `PATCH /:id` (the Edit
|
||||
* user form: display name, email, role, disabled, a new password, workspaces
|
||||
|
||||
+39
-2
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @file WebSocket functionalities for real-time communication with clients, including connection management, heartbeat for detecting dead connections, and broadcasting messages to all connected clients.
|
||||
* @file WebSocket functionalities for real-time communication with clients, including connection management, heartbeat for detecting dead connections, and broadcasting messages to all connected clients (server mode: each user only what they may see, and an admin/analyst's session frames marked `mine` when the session is their own).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -9,6 +9,14 @@ const { isWebSocketLoggedIn, isServerMode, userFromRequest } = require("./lib/au
|
||||
const { broadcastAudience, exactAudience, audienceIncludes, canSeeRun } = require("./lib/scope");
|
||||
|
||||
let wss = null;
|
||||
// Session-scoped broadcasts an admin/analyst gets with `mine` (see broadcast).
|
||||
const MINE_TYPES = new Set([
|
||||
"session_created",
|
||||
"session_updated",
|
||||
"agent_created",
|
||||
"agent_updated",
|
||||
"new_event",
|
||||
]);
|
||||
// How often an open terminal's login is re-checked in server mode.
|
||||
const PTY_RECHECK_MS = Number(process.env.AGENTHUB_PTY_RECHECK_MS) || 10000;
|
||||
|
||||
@@ -230,7 +238,8 @@ function initPtyWebSocket(server) {
|
||||
|
||||
function broadcast(type, data) {
|
||||
if (!wss) return;
|
||||
const message = JSON.stringify({ type, data, timestamp: new Date().toISOString() });
|
||||
const timestamp = new Date().toISOString();
|
||||
const message = JSON.stringify({ type, data, timestamp });
|
||||
// Server mode: worked out once, and only when a member is connected.
|
||||
let audience;
|
||||
// A message for exactly these users (a member's own config change): no
|
||||
@@ -255,6 +264,24 @@ function broadcast(type, data) {
|
||||
return audienceIncludes(audience, client.user);
|
||||
};
|
||||
let stripped;
|
||||
// Server mode: an admin/analyst gets every user's session news, so a
|
||||
// session-scoped frame tells them whether it is their OWN (`mine`) — the
|
||||
// pet counts only those. Worked out once per broadcast, on first need.
|
||||
const owned = MINE_TYPES.has(type) && isServerMode();
|
||||
let mineMsg;
|
||||
let otherMsg;
|
||||
const forStaff = (client) => {
|
||||
if (audience === undefined) {
|
||||
try {
|
||||
audience = broadcastAudience(type, data);
|
||||
} catch {
|
||||
audience = { staffOnly: true };
|
||||
}
|
||||
}
|
||||
const mine = Boolean(audience && audience.owners && audience.owners.has(client.user.id));
|
||||
if (mine) return (mineMsg ??= JSON.stringify({ type, data, timestamp, mine: true }));
|
||||
return (otherMsg ??= JSON.stringify({ type, data, timestamp, mine: false }));
|
||||
};
|
||||
wss.clients.forEach((client) => {
|
||||
if (client.readyState === 1 && reaches(client)) {
|
||||
try {
|
||||
@@ -264,6 +291,10 @@ function broadcast(type, data) {
|
||||
client.send(stripped);
|
||||
return;
|
||||
}
|
||||
if (owned && !only && client.user && !member) {
|
||||
client.send(forStaff(client));
|
||||
return;
|
||||
}
|
||||
client.send(message);
|
||||
} catch {
|
||||
// Client closed between readyState check and send — safe to ignore
|
||||
@@ -331,7 +362,13 @@ function closeWebSocket() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Tests only: broadcast to these fake clients (`{readyState, user, send}`). */
|
||||
function _setClientsForTest(clients) {
|
||||
wss = clients ? { clients } : null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
_setClientsForTest,
|
||||
initWebSocket,
|
||||
initPtyWebSocket,
|
||||
broadcast,
|
||||
|
||||
Reference in New Issue
Block a user